diff --git a/.github/README.md b/.github/README.md index 49d2bc5..8c316e4 100644 --- a/.github/README.md +++ b/.github/README.md @@ -15,7 +15,6 @@ The following editors are currently supported: - Markdown - Block Grid - Block List -- Nested Content - Textstring - Textarea @@ -35,10 +34,10 @@ When creating a new data type, the default will be 200 words per minute. ## Security -> [!NOTE] -> This project takes security and support seriously. -> Please visit the [Security](https://github.com/jcdcdev/jcdcdev.Umbraco.ReadingTime?tab=security-ov-file) page for more information. - +> [!NOTE] +> This project takes security and support seriously. +> Please visit the [Security](https://github.com/jcdcdev/jcdcdev.Umbraco.ReadingTime?tab=security-ov-file) page for more information. + ## Contributing @@ -52,4 +51,4 @@ Thank you to the following projects and individuals for their contributions. Hig - LottePitcher - [opinionated-package-starter](https://github.com/LottePitcher/opinionated-package-starter) - + diff --git a/docs/README_nuget.md b/docs/README_nuget.md index 36c319b..6449f8b 100644 --- a/docs/README_nuget.md +++ b/docs/README_nuget.md @@ -15,13 +15,12 @@ The following editors are currently supported: - Markdown - Block Grid - Block List -- Nested Content - Textstring - Textarea ## Security -This project takes security and support seriously. +This project takes security and support seriously. Please visit the [Security](https://github.com/jcdcdev/jcdcdev.Umbraco.ReadingTime?tab=security-ov-file) page for more information. ## Contributing @@ -35,4 +34,4 @@ Thank you to the following projects and individuals for their contributions. Hig - LottePitcher - [opinionated-package-starter](https://github.com/LottePitcher/opinionated-package-starter) - + diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/openapi-ts.config.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/openapi-ts.config.ts deleted file mode 100644 index 2cc145d..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/openapi-ts.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig, defaultPlugins } from '@hey-api/openapi-ts'; - -export default defineConfig({ - input: 'http://localhost:54813/umbraco/swagger/ReadingTime/swagger.json', - plugins: [ - ...defaultPlugins, - { - name: '@hey-api/client-fetch', - exportFromIndex: true, - throwOnError: true, - }, - { - name: '@hey-api/typescript', - enums: 'typescript', - readOnlyWriteOnlyBehavior: 'off', - }, - { - name: '@hey-api/sdk', - asClass: true, - } - ], - output: { - format: 'prettier', - path: './src/api', - } -}); diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/package.json b/src/jcdcdev.Umbraco.ReadingTime.Client/package.json index 83d7419..941c776 100644 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/package.json +++ b/src/jcdcdev.Umbraco.ReadingTime.Client/package.json @@ -6,8 +6,7 @@ "scripts": { "dev": "vite build --watch --emptyOutDir", "build": "tsc && vite build --emptyOutDir", - "preview": "vite preview", - "generate": "openapi-ts" + "preview": "vite preview" }, "devDependencies": { "lit": "^3.3.2", diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client.gen.ts deleted file mode 100644 index eadcc39..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client.gen.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { type ClientOptions, type Config, createClient, createConfig } from './client'; -import type { ClientOptions as ClientOptions2 } from './types.gen'; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = (override?: Config) => Config & T>; - -export const client = createClient(createConfig({ - baseUrl: 'http://localhost:54813', - throwOnError: true -})); diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/client.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/client.gen.ts deleted file mode 100644 index a439d27..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/client.gen.ts +++ /dev/null @@ -1,268 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { - Client, - Config, - RequestOptions, - ResolvedRequestOptions, -} from './types.gen'; -import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams, -} from './utils.gen'; - -type ReqInit = Omit & { - body?: any; - headers: ReturnType; -}; - -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors< - Request, - Response, - unknown, - ResolvedRequestOptions - >(); - - const beforeRequest = async (options: RequestOptions) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, - }; - - if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const url = buildUrl(opts); - - return { opts, url }; - }; - - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; - - let request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - let response = await _fetch(request); - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response, - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if ( - response.status === 204 || - response.headers.get('Content-Length') === '0' - ) { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result, - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'json': - case 'text': - data = await response[parseAs](); - break; - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - const error = jsonError ?? textError; - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, response, request, opts)) as string; - } - } - - finalError = finalError || ({} as string); - - if (opts.throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; - }; - - const makeMethodFn = - (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = - (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - url, - }); - }; - - return { - buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/index.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/index.ts deleted file mode 100644 index cbf8dfe..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export { createClient } from './client.gen'; -export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - OptionsLegacyParser, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/types.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/types.gen.ts deleted file mode 100644 index 1a005b5..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/types.gen.ts +++ /dev/null @@ -1,268 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; -import type { - Client as CoreClient, - Config as CoreConfig, -} from '../core/types.gen'; -import type { Middleware } from './utils.gen'; - -export type ResponseStyle = 'data' | 'fields'; - -export interface Config - extends Omit, - CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: - | 'arrayBuffer' - | 'auto' - | 'blob' - | 'formData' - | 'json' - | 'stream' - | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; -} - -export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; -} - -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - serializedBody?: string; -} - -export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record - ? TData[keyof TData] - : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? - | (TData extends Record - ? TData[keyof TData] - : TData) - | undefined - : ( - | { - data: TData extends Record - ? TData[keyof TData] - : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record - ? TError[keyof TError] - : TError; - } - ) & { - request: Request; - response: Response; - } - >; - -export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; -} - -type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => RequestResult; - -type SseFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - -type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'> & - Pick< - Required>, - 'method' - >, -) => RequestResult; - -type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - }, ->( - options: Pick & Options, -) => string; - -export type Client = CoreClient< - RequestFn, - Config, - MethodFn, - BuildUrlFn, - SseFn -> & { - interceptors: Middleware; -}; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = ( - override?: Config, -) => Config & T>; - -export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; -} - -type OmitKeys = Pick>; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields', -> = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' -> & - Omit; - -export type OptionsLegacyParser< - TData = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = TData extends { body?: any } - ? TData extends { headers?: any } - ? OmitKeys< - RequestOptions, - 'body' | 'headers' | 'url' - > & - TData - : OmitKeys< - RequestOptions, - 'body' | 'url' - > & - TData & - Pick, 'headers'> - : TData extends { headers?: any } - ? OmitKeys< - RequestOptions, - 'headers' | 'url' - > & - TData & - Pick, 'body'> - : OmitKeys, 'url'> & - TData; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/utils.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/utils.gen.ts deleted file mode 100644 index b4bcc4d..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/client/utils.gen.ts +++ /dev/null @@ -1,331 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; - -export const createQuerySerializer = ({ - allowReserved, - array, - object, -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved, - explode: true, - name, - style: 'form', - value, - ...array, - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...object, - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved, - name, - value: value as string, - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; -}; - -/** - * Infers parseAs value from provided Content-Type header. - */ -export const getParseAs = ( - contentType: string | null, -): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if ( - cleanContent.startsWith('application/json') || - cleanContent.endsWith('+json') - ) { - return 'json'; - } - - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => - cleanContent.startsWith(type), - ) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } - - return; -}; - -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { - headers: Headers; - }) => { - for (const auth of security) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } -}; - -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - -export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; - -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - -export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> -): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = - header instanceof Headers - ? headersEntries(header) - : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e. their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); - } - } - } - return mergedHeaders; -}; - -type ErrInterceptor = ( - error: Err, - response: Res, - request: Req, - options: Options, -) => Err | Promise; - -type ReqInterceptor = ( - request: Req, - options: Options, -) => Req | Promise; - -type ResInterceptor = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise; - -class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update( - id: number | Interceptor, - fn: Interceptor, - ): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } -} - -export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; -} - -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>(), -}); - -const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form', - }, - object: { - explode: true, - style: 'deepObject', - }, -}); - -const defaultHeaders = { - 'Content-Type': 'application/json', -}; - -export const createConfig = ( - override: Config & T> = {}, -): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override, -}); diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/auth.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/auth.gen.ts deleted file mode 100644 index f8a7326..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/auth.gen.ts +++ /dev/null @@ -1,42 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AuthToken = string | undefined; - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = - typeof callback === 'function' ? await callback(auth) : callback; - - if (!token) { - return; - } - - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } - - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } - - return token; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/bodySerializer.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/bodySerializer.gen.ts deleted file mode 100644 index 49cd892..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/bodySerializer.gen.ts +++ /dev/null @@ -1,92 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { - ArrayStyle, - ObjectStyle, - SerializerOptions, -} from './pathSerializer.gen'; - -export type QuerySerializer = (query: Record) => string; - -export type BodySerializer = (body: any) => any; - -export interface QuerySerializerOptions { - allowReserved?: boolean; - array?: SerializerOptions; - object?: SerializerOptions; -} - -const serializeFormDataPair = ( - data: FormData, - key: string, - value: unknown, -): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -const serializeUrlSearchParamsPair = ( - data: URLSearchParams, - key: string, - value: unknown, -): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -export const formDataBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): FormData => { - const data = new FormData(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); - - return data; - }, -}; - -export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => - typeof value === 'bigint' ? value.toString() : value, - ), -}; - -export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): string => { - const data = new URLSearchParams(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); - - return data.toString(); - }, -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/params.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/params.gen.ts deleted file mode 100644 index 71c88e8..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/params.gen.ts +++ /dev/null @@ -1,153 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -type Slot = 'body' | 'headers' | 'path' | 'query'; - -export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - }; - -export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; -} - -export type FieldsConfig = ReadonlyArray; - -const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); - -type KeyMap = Map< - string, - { - in: Slot; - map?: string; - } ->; - -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }); - } - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; -}; - -interface Params { - body: unknown; - headers: Record; - path: Record; - query: Record; -} - -const stripEmptySlots = (params: Params) => { - for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === 'object' && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } -}; - -export const buildClientParams = ( - args: ReadonlyArray, - fields: FieldsConfig, -) => { - const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, - }; - - const map = buildKeyMap(fields); - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - (params[field.in] as Record)[name] = arg; - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; - } else { - const extra = extraPrefixes.find(([prefix]) => - key.startsWith(prefix), - ); - - if (extra) { - const [prefix, slot] = extra; - (params[slot] as Record)[ - key.slice(prefix.length) - ] = value; - } else { - for (const [slot, allowed] of Object.entries( - config.allowExtra ?? {}, - )) { - if (allowed) { - (params[slot as Slot] as Record)[key] = value; - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/pathSerializer.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/pathSerializer.gen.ts deleted file mode 100644 index 8d99931..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/pathSerializer.gen.ts +++ /dev/null @@ -1,181 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -interface SerializeOptions - extends SerializePrimitiveOptions, - SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; -} - -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } -}; - -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[]; -}) => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; -}; - -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam) => { - if (value === undefined || value === null) { - return ''; - } - - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; -}) => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [ - ...values, - key, - allowReserved ? (v as string) : encodeURIComponent(v as string), - ]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/queryKeySerializer.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/queryKeySerializer.gen.ts deleted file mode 100644 index d3bb683..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,136 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => - a.localeCompare(b), - ); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = ( - value: unknown, -): JsonValue | undefined => { - if (value === null) { - return null; - } - - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if ( - typeof URLSearchParams !== 'undefined' && - value instanceof URLSearchParams - ) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/serverSentEvents.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/serverSentEvents.gen.ts deleted file mode 100644 index f8fd78e..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,264 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit< - RequestInit, - 'method' -> & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult< - TData = unknown, - TReturn = void, - TNext = unknown, -> = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export const createSseClient = ({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined; - - const sleep = - sseSleepFn ?? - ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) - throw new Error( - `SSE failed: ${response.status} ${response.statusText}`, - ); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt( - line.replace(/^retry:\s*/, ''), - 10, - ); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if ( - sseMaxRetryAttempts !== undefined && - attempt >= sseMaxRetryAttempts - ) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min( - retryDelay * 2 ** (attempt - 1), - sseMaxRetryDelay ?? 30000, - ); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/types.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/types.gen.ts deleted file mode 100644 index 643c070..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/types.gen.ts +++ /dev/null @@ -1,118 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; -import type { - BodySerializer, - QuerySerializer, - QuerySerializerOptions, -} from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; - -export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never, -> = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] - ? { sse?: never } - : { sse: { [K in HttpMethod]: SseFn } }); - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - | string - | number - | boolean - | (string | number | boolean)[] - | null - | undefined - | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; -} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true - ? never - : K]: T[K]; -}; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/utils.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/utils.gen.ts deleted file mode 100644 index 0b5389d..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/core/utils.gen.ts +++ /dev/null @@ -1,143 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}) { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/index.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/index.ts deleted file mode 100644 index 3731393..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type * from './types.gen'; -export * from './client.gen'; -export * from './sdk.gen'; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/sdk.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/sdk.gen.ts deleted file mode 100644 index 9f8401c..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/sdk.gen.ts +++ /dev/null @@ -1,28 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Client, Options as Options2, TDataShape } from './client'; -import { client } from './client.gen'; -import type { GetUmbracoReadingTimeApiV1Data, GetUmbracoReadingTimeApiV1Responses } from './types.gen'; - -export type Options = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: Record; -}; - -export class ReadingTime { - public static getUmbracoReadingTimeApiV1(options?: Options) { - return (options?.client ?? client).get({ - url: '/umbraco/ReadingTime/api/v1', - ...options - }); - } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/types.gen.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/types.gen.ts deleted file mode 100644 index 55ef9a5..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/api/types.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: 'http://localhost:54813' | (string & {}); -}; - -export type ReadingTimeResponse = { - updateDate: string; - readingTime: string; -}; - -export type GetUmbracoReadingTimeApiV1Data = { - body?: never; - path?: never; - query?: { - contentKey?: string; - dataTypeKey?: string; - culture?: string; - }; - url: '/umbraco/ReadingTime/api/v1'; -}; - -export type GetUmbracoReadingTimeApiV1Responses = { - /** - * OK - */ - 200: ReadingTimeResponse; -}; - -export type GetUmbracoReadingTimeApiV1Response = GetUmbracoReadingTimeApiV1Responses[keyof GetUmbracoReadingTimeApiV1Responses]; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/context/reading-time.context.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/context/reading-time.context.ts deleted file mode 100644 index bbbb1b2..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/context/reading-time.context.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {UmbControllerBase} from "@umbraco-cms/backoffice/class-api"; -import {UmbControllerHost} from "@umbraco-cms/backoffice/controller-api"; -import {UmbDataSourceResponse} from "@umbraco-cms/backoffice/repository"; -import {UmbContextToken} from "@umbraco-cms/backoffice/context-api"; -import {ReadingTimeResponse} from "../api"; -import {ReadingTimeRepository} from "../repository/reading-time.repository.ts"; - -export class ReadingTimeContext extends UmbControllerBase { - #repository: ReadingTimeRepository; - - constructor(host: UmbControllerHost) { - super(host); - this.#repository = new ReadingTimeRepository(this); - this.provideContext(READING_TIME_CONTEXT_TOKEN, this); - } - - async getReadingTime(contentKey: string, dataTypeKey: string, culture?: string): Promise> { - return await this.#repository.getReadingTime(contentKey, dataTypeKey, culture); - } -} - -export const READING_TIME_CONTEXT_TOKEN = - new UmbContextToken("ReadingTimeContext"); diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/manifest.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/manifest.ts index 3da72d3..c42c820 100644 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/manifest.ts +++ b/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/manifest.ts @@ -1,6 +1,4 @@ -import {ManifestPropertyEditorUi} from "@umbraco-cms/backoffice/property-editor"; - -const editors: Array = [ +const editors: Array = [ { type: "propertyEditorUi", alias: "jcdcdev.ReadingTime", @@ -9,7 +7,7 @@ const editors: Array = [ elementName: "reading-time-property-editor-ui", meta: { label: "Reading Time", - icon: "icon-list", + icon: "icon-timer", group: "common", propertyEditorSchemaAlias: "jcdcdev.ReadingTime", settings: { @@ -72,7 +70,7 @@ const editors: Array = [ }, { alias: "maxUnit", - value: "Minute" + value: "Hour" }, { alias: "hideVariationWarning", diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/reading-time.editor.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/reading-time.editor.ts index 10e4917..11bfa59 100644 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/reading-time.editor.ts +++ b/src/jcdcdev.Umbraco.ReadingTime.Client/src/editors/reading-time.editor.ts @@ -1,176 +1,173 @@ -import {LitElement, html, customElement, property, state} from "@umbraco-cms/backoffice/external/lit"; -import {UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement} from "@umbraco-cms/backoffice/property-editor"; -import {UmbElementMixin} from "@umbraco-cms/backoffice/element-api"; -import {UMB_ENTITY_CONTEXT} from "@umbraco-cms/backoffice/entity"; -import {UMB_PROPERTY_CONTEXT} from "@umbraco-cms/backoffice/property"; -import {UMB_CONTENT_PROPERTY_CONTEXT} from "@umbraco-cms/backoffice/content"; -import {ReadingTimeResponse} from "../api"; -import {READING_TIME_CONTEXT_TOKEN, ReadingTimeContext} from "../context/reading-time.context.ts"; -import {css, nothing, PropertyValues} from "lit"; -import {UMB_ACTION_EVENT_CONTEXT} from "@umbraco-cms/backoffice/action"; -import {UmbRequestReloadStructureForEntityEvent} from "@umbraco-cms/backoffice/entity-action"; +import { LitElement, html, css, customElement, property, state, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; +import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; + +const UNIT_ORDER = ['second', 'minute', 'hour', 'day'] as const; +type TimeUnit = (typeof UNIT_ORDER)[number]; @customElement('reading-time-property-editor-ui') export default class ReadingTimePropertyEditorUi extends UmbElementMixin(LitElement) implements UmbPropertyEditorUiElement { + @property({ type: Number }) + public value?: number; - @property({type: String}) - public value = ""; - - #readingTimeContext?: ReadingTimeContext; - - @state() - private hideVariationWarning: boolean = false; - @state() - private loading: boolean = false; - @state() - private contentKey?: string; - @state() - private dataTypeKey?: string; - @state() - private culture?: string; - @state() - private data?: ReadingTimeResponse; - @state() - private initialised: boolean = false - static styles = [css` - .alert { - background-color: darkgoldenrod; - padding: 5px; - } + @state() + private _minUnit: TimeUnit = 'minute'; - .icon-container { - display: flex; - align-items: center; - } + @state() + private _maxUnit: TimeUnit = 'hour'; - .icon { - margin-right: 5px; - } - `] - - constructor() { - super(); - - this.consumeContext(READING_TIME_CONTEXT_TOKEN, (context) => { - this.#readingTimeContext = context; - }); - - this.consumeContext(UMB_ENTITY_CONTEXT, (context) => { - this.contentKey = context?.getUnique() ?? undefined; - }); - - this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { - this.culture = context?.getVariantId()?.culture ?? undefined; - }); - - this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (context) => { - context?.addEventListener(UmbRequestReloadStructureForEntityEvent.TYPE, () => { - if (!this.initialised) { - return; - } - this.loading = true; - const interval = setInterval(async () => { - if (!(this.contentKey && this.dataTypeKey)) { - return; - } - - const response = await this.#readingTimeContext?.getReadingTime(this.contentKey, this.dataTypeKey, this.culture); - if (!response || !response.data?.updateDate) { - return; - } - - if (response.data.updateDate === this.data?.updateDate) { - return; - } - - this.data = response.data; - this.loading = false; - clearInterval(interval); - }, 2500); - }); - }); - - this.consumeContext(UMB_CONTENT_PROPERTY_CONTEXT, (context) => { - context?.dataType.subscribe((dataType) => { - this.dataTypeKey = dataType?.unique - }).unsubscribe(); - }); - } + @state() + private _hideVariationWarning: boolean = false; - @property({attribute: false}) - public set config(config: UmbPropertyEditorConfigCollection) { - this.hideVariationWarning = config.getValueByAlias("hideVariationWarning") ?? false; - } + @state() + private _culture?: string; - render() { - if (this.loading) { - return html - ` - - `; - } + constructor() { + super(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this._culture = context?.getVariantId()?.culture ?? undefined; + }); + } - if (!this.data) { - return html - ` -
Save and publish to calculate reading time
- `; - } + @property({ attribute: false }) + public set config(config: UmbPropertyEditorConfigCollection) { + const minVal = config.getValueByAlias('minUnit'); + const maxVal = config.getValueByAlias('maxUnit'); + this._hideVariationWarning = config.getValueByAlias('hideVariationWarning') ?? false; - const alert = this.renderVariationAlert(); - return html - ` -
- ${alert} - ${this.data.readingTime} -
- `; - } + const resolvedMin = Array.isArray(minVal) ? minVal[0] : minVal; + const resolvedMax = Array.isArray(maxVal) ? maxVal[0] : maxVal; - renderVariationAlert() { - if (this.hideVariationWarning || this.culture) { - return nothing; - } + const normalizedMin = resolvedMin?.toLowerCase() as TimeUnit | undefined; + const normalizedMax = resolvedMax?.toLowerCase() as TimeUnit | undefined; - return html - ` -
-
- - Language specific properties are not used in this calculation -
-
- `; + if (normalizedMin && UNIT_ORDER.includes(normalizedMin)) { + this._minUnit = normalizedMin; } + if (normalizedMax && UNIT_ORDER.includes(normalizedMax)) { + this._maxUnit = normalizedMax; + } + } - protected updated(_changedProperties: PropertyValues) { - if (!this.initialised) { - if (this.contentKey && this.dataTypeKey) { - this.init(); - } - } + #formatTime(totalSeconds: number): string { + if (totalSeconds <= 0) { + return this._minUnit === 'second' ? 'Less than a second' : 'Less than a minute'; } - private async init() { - if (this.initialised) { - return; + const minIdx = UNIT_ORDER.indexOf(this._minUnit); + const maxIdx = UNIT_ORDER.indexOf(this._maxUnit); + + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const allUnits: { unit: TimeUnit; value: number }[] = [ + { unit: 'day', value: days }, + { unit: 'hour', value: hours }, + { unit: 'minute', value: minutes }, + { unit: 'second', value: seconds }, + ]; + + // Filter to only units within the min/max range + const filtered = allUnits.filter((u) => { + const idx = UNIT_ORDER.indexOf(u.unit); + return idx >= minIdx && idx <= maxIdx; + }); + + // If min unit is above seconds, round up the smallest visible unit + if (minIdx > 0 && filtered.length > 0) { + const belowMinUnits = allUnits.filter((u) => UNIT_ORDER.indexOf(u.unit) < minIdx); + const hasRemainder = belowMinUnits.some((u) => u.value > 0); + if (hasRemainder) { + const smallest = filtered[filtered.length - 1]; + smallest.value += 1; + // Handle carry-over + for (let i = filtered.length - 1; i > 0; i--) { + const current = filtered[i]; + const parent = filtered[i - 1]; + const limit = current.unit === 'second' ? 60 : current.unit === 'minute' ? 60 : current.unit === 'hour' ? 24 : Infinity; + if (current.value >= limit) { + current.value -= limit; + parent.value += 1; + } } + } + } - this.loading = true; - const result = await this.#readingTimeContext?.getReadingTime(this.contentKey!, this.dataTypeKey!, this.culture!); - this.loading = false; - this.initialised = true; + const labels: Record = { + day: ['day', 'days'], + hour: ['hour', 'hours'], + minute: ['minute', 'minutes'], + second: ['second', 'seconds'], + }; - if (!result) { - return; - } + const parts = filtered + .filter((u) => u.value > 0) + .map((u) => `${u.value} ${u.value === 1 ? labels[u.unit][0] : labels[u.unit][1]}`); + + if (parts.length === 0) { + const minLabel = this._minUnit === 'second' ? 'a second' : this._minUnit === 'minute' ? 'a minute' : this._minUnit === 'hour' ? 'an hour' : 'a day'; + return `Less than ${minLabel}`; + } + + return parts.join(', '); + } - this.data = result.data; + #renderVariationAlert() { + if (this._hideVariationWarning || this._culture) { + return nothing; } + + return html` +
+
+ + Language specific properties are not used in this calculation +
+
+ `; + } + + render() { + if (this.value == null) { + return html`Reading time will be calculated on save.`; + } + + return html` +
+ ${this.#renderVariationAlert()} + ${this.#formatTime(this.value)} +
+ `; + } + + static styles = css` + :host { + display: block; + } + em { + color: var(--uui-color-text-alt); + } + .alert { + background-color: darkgoldenrod; + padding: 5px; + margin-bottom: 5px; + } + .icon-container { + display: flex; + align-items: center; + } + .icon { + margin-right: 5px; + } + `; } declare global { - interface HTMLElementTagNameMap { - 'reading-time-property-editor-ui': ReadingTimePropertyEditorUi; - } + interface HTMLElementTagNameMap { + 'reading-time-property-editor-ui': ReadingTimePropertyEditorUi; + } } diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/index.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/index.ts index aa2c816..9afc159 100644 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/index.ts +++ b/src/jcdcdev.Umbraco.ReadingTime.Client/src/index.ts @@ -1,33 +1,8 @@ -import {manifests as editors} from './editors/manifest.ts'; -import {UMB_AUTH_CONTEXT} from "@umbraco-cms/backoffice/auth"; -import {UmbEntryPointOnInit} from "@umbraco-cms/backoffice/extension-api"; -import {ReadingTimeContext} from "./context/reading-time.context.ts"; -import {client} from './api'; +import { manifests as editors } from './editors/manifest.ts'; +import { UmbEntryPointOnInit } from "@umbraco-cms/backoffice/extension-api"; export const onInit: UmbEntryPointOnInit = (_host, extensionRegistry) => { extensionRegistry.registerMany([ ...editors, ]); - - _host.consumeContext(UMB_AUTH_CONTEXT, (_auth) => { - if (!_auth) { - console.error('No auth context found'); - return; - } - - const config = _auth.getOpenApiConfiguration(); - client.setConfig({ - auth: config.token, - baseUrl: config.base, - credentials: config.credentials, - }); - - client.interceptors.request.use(async (request, _options) => { - const token = await _auth.getLatestToken(); - request.headers.set('Authorization', `Bearer ${token}`); - return request; - }); - - new ReadingTimeContext(_host); - }); }; diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.datasource.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.datasource.ts deleted file mode 100644 index 7941e2d..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.datasource.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api"; -import { UmbDataSourceResponse } from "@umbraco-cms/backoffice/repository"; -import { tryExecute } from "@umbraco-cms/backoffice/resources"; -import { ReadingTimeResponse, ReadingTime } from "../api"; - -export class ReadingTimeDataSource implements IReadingTimeDataSource { - - #host: UmbControllerHost; - - constructor(host: UmbControllerHost) { - this.#host = host; - } - - async getReadingTime(contentKey: string, dataTypeKey: string, culture?: string): Promise> { - return await tryExecute(this.#host, ReadingTime.getUmbracoReadingTimeApiV1({ - query: { - contentKey: contentKey, - dataTypeKey: dataTypeKey, - culture: culture, - } - })) - } - -} - -export interface IReadingTimeDataSource { - getReadingTime(contentKey: string, dataTypeKey: string, culture?: string): Promise>; -} - diff --git a/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.repository.ts b/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.repository.ts deleted file mode 100644 index addd8e9..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime.Client/src/repository/reading-time.repository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {UmbControllerHost} from "@umbraco-cms/backoffice/controller-api"; -import {UmbDataSourceResponse} from "@umbraco-cms/backoffice/repository"; -import {UmbControllerBase} from "@umbraco-cms/backoffice/class-api"; -import {ReadingTimeResponse} from "../api"; -import {IReadingTimeDataSource, ReadingTimeDataSource} from "./reading-time.datasource.ts"; - -export class ReadingTimeRepository extends UmbControllerBase { - #resource: IReadingTimeDataSource; - - constructor(host: UmbControllerHost) { - super(host); - this.#resource = new ReadingTimeDataSource(host); - } - - async getReadingTime(contentKey: string, dataTypeKey: string, culture?: string): Promise> { - return this.#resource.getReadingTime(contentKey, dataTypeKey, culture); - } -} - diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/Constants.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/Constants.cs index b31de31..abba37a 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/Constants.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/Constants.cs @@ -1,4 +1,4 @@ -namespace jcdcdev.Umbraco.ReadingTime.Core; +namespace jcdcdev.Umbraco.ReadingTime.Core; public static class Constants { @@ -19,11 +19,11 @@ public static class Configuration public const string HideVariationWarning = "hideVariationWarning"; } - public static class Api + public static class HealthCheck { - public const string ApiName = "ReadingTime"; - public const string Title = "Reading Time"; - public const string Description = "Reading Time API"; - public const string GroupName = "Reading Time"; + public const string Id = "E1F5B4A2-3C6D-4E8F-9A0B-1C2D3E4F5A6B"; + public const string Name = "Reading Time Data"; + public const string Description = "Checks that all content items with Reading Time properties have calculated values."; + public const string Group = "Content"; } } diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/Extensions/UmbracoBuilderExtensions.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/Extensions/UmbracoBuilderExtensions.cs index 2fa7d71..20afaaf 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/Extensions/UmbracoBuilderExtensions.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/Extensions/UmbracoBuilderExtensions.cs @@ -2,8 +2,6 @@ using jcdcdev.Umbraco.ReadingTime.Infrastructure; using jcdcdev.Umbraco.ReadingTime.Infrastructure.Indexing; using jcdcdev.Umbraco.ReadingTime.Infrastructure.Migrations; -using jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; -using jcdcdev.Umbraco.ReadingTime.Web; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Notifications; @@ -17,14 +15,10 @@ public static class UmbracoBuilderExtensions public static IUmbracoBuilder AddReadingTime(this IUmbracoBuilder builder) { builder.PackageMigrationPlans().Add(); - builder.Services.AddSingleton(); - builder.AddNotificationAsyncHandler(); - builder.AddNotificationAsyncHandler(); + builder.Services.AddScoped(); + builder.AddNotificationAsyncHandler(); builder.ReadingTimeValueProviders().Append(); - builder.Services.AddSingleton(); - builder.ReadingTimeValueProviders().Append(); - builder.Services.ConfigureOptions(); builder.Services.AddSingleton(); return builder; diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/IReadingTimeService.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/IReadingTimeService.cs index d920fd9..2066257 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/IReadingTimeService.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/IReadingTimeService.cs @@ -1,14 +1,8 @@ -using jcdcdev.Umbraco.ReadingTime.Core.Models; using Umbraco.Cms.Core.Models; namespace jcdcdev.Umbraco.ReadingTime.Core; public interface IReadingTimeService { - Task ScanTree(int homeId); - Task ScanAll(); - Task Process(IContent item); - Task DeleteAsync(Guid key); - Task GetAsync(Guid key, Guid dataTypeKey); - Task GetAsync(Guid key, int dataTypeId); + Task CalculateAndSetReadingTime(IContent content); } diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeDto.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeDto.cs deleted file mode 100644 index 8f10afc..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Umbraco.Extensions; - -namespace jcdcdev.Umbraco.ReadingTime.Core.Models; - -public class ReadingTimeDto -{ - public List Data { get; init; } = new(); - public int Id { get; init; } - public Guid Key { get; init; } - public int DataTypeId { get; init; } - public Guid DataTypeKey { get; set; } - public DateTime UpdateDate { get; set; } - - public ReadingTimeVariantDto? Value(string? culture = null) => Data.FirstOrDefault(x => x?.Culture.InvariantEquals(culture) ?? false); -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeVariantDto.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeVariantDto.cs deleted file mode 100644 index 2f1523e..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/Models/ReadingTimeVariantDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Runtime.Serialization; - -namespace jcdcdev.Umbraco.ReadingTime.Core.Models; - -public class ReadingTimeVariantDto -{ - [DataMember(Name = "culture")] public string? Culture { get; set; } - - [DataMember(Name = "readingTime")] public TimeSpan? ReadingTime { get; set; } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimeConfiguration.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimeConfiguration.cs index 5138024..4c19a0e 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimeConfiguration.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimeConfiguration.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Humanizer; using Umbraco.Cms.Core.PropertyEditors; diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimePropertyValueConverter.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimePropertyValueConverter.cs index f8751d3..6da80a0 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimePropertyValueConverter.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/PropertyEditors/ReadingTimePropertyValueConverter.cs @@ -1,4 +1,4 @@ -using jcdcdev.Umbraco.ReadingTime.Core.Models; +using jcdcdev.Umbraco.ReadingTime.Core.Models; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; @@ -6,12 +6,37 @@ namespace jcdcdev.Umbraco.ReadingTime.Core.PropertyEditors; public class ReadingTimePropertyValueConverter( - IReadingTimeService readingTimeService, IVariationContextAccessor variationContextAccessor, ILogger logger) : PropertyValueConverterBase { - private readonly ILogger _logger = logger; + public override bool IsConverter(IPublishedPropertyType propertyType) => + propertyType.EditorAlias == Constants.PropertyEditorAlias; + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => + typeof(ReadingTimeValueModel); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => + PropertyCacheLevel.Element; + + public override object? ConvertSourceToIntermediate( + IPublishedElement owner, + IPublishedPropertyType propertyType, + object? source, + bool preview) + { + if (source is int seconds) + { + return TimeSpan.FromSeconds(seconds); + } + + if (source is string str && int.TryParse(str, out var parsed)) + { + return TimeSpan.FromSeconds(parsed); + } + + return null; + } public override object? ConvertIntermediateToObject( IPublishedElement owner, @@ -20,36 +45,19 @@ public class ReadingTimePropertyValueConverter( object? inter, bool preview) { - if (inter is not Guid key) + if (inter is not TimeSpan readingTime) { return null; } - var model = readingTimeService.GetAsync(key, propertyType.DataType.Id).GetAwaiter().GetResult(); - var culture = variationContextAccessor.VariationContext?.Culture; var config = propertyType.DataType.ConfigurationAs(); if (config is null) { - _logger.LogError("ReadingTime configuration is missing."); + logger.LogError("ReadingTime configuration is missing."); return null; } - var output = model?.Value(culture) ?? model?.Value(); - if (output is null) - { - return null; - } - - return new ReadingTimeValueModel(output.ReadingTime, config.Min, config.Max, output.Culture); + var culture = variationContextAccessor.VariationContext?.Culture; + return new ReadingTimeValueModel(readingTime, config.Min, config.Max, culture); } - - public override object? ConvertSourceToIntermediate( - IPublishedElement owner, - IPublishedPropertyType propertyType, - object? source, - bool preview) => owner.Key; - - public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(ReadingTimeValueModel); - - public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias == Constants.PropertyEditorAlias; } diff --git a/src/jcdcdev.Umbraco.ReadingTime/Core/ReadingTimeNotificationHandler.cs b/src/jcdcdev.Umbraco.ReadingTime/Core/ReadingTimeNotificationHandler.cs index ba7ff40..f186b55 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Core/ReadingTimeNotificationHandler.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Core/ReadingTimeNotificationHandler.cs @@ -1,26 +1,16 @@ -using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; namespace jcdcdev.Umbraco.ReadingTime.Core; -public class ReadingTimeNotificationHandler( - IReadingTimeService readingTimeService) : - INotificationAsyncHandler, - INotificationAsyncHandler +public class ReadingTimeNotificationHandler(IReadingTimeService calculationService) + : INotificationAsyncHandler { - public async Task HandleAsync(ContentDeletingNotification notification, CancellationToken cancellationToken) + public async Task HandleAsync(ContentSavingNotification notification, CancellationToken cancellationToken) { - foreach (var content in notification.DeletedEntities) + foreach (var content in notification.SavedEntities) { - await readingTimeService.DeleteAsync(content.Key); - } - } - - public async Task HandleAsync(ContentPublishedNotification notification, CancellationToken cancellationToken) - { - foreach (var item in notification.PublishedEntities) - { - await readingTimeService.Process(item); + await calculationService.CalculateAndSetReadingTime(content); } } } diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/HealthChecks/ReadingTimeHealthCheck.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/HealthChecks/ReadingTimeHealthCheck.cs new file mode 100644 index 0000000..838e195 --- /dev/null +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/HealthChecks/ReadingTimeHealthCheck.cs @@ -0,0 +1,300 @@ +using jcdcdev.Umbraco.ReadingTime.Core; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.HealthChecks; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.HealthChecks; + +[HealthCheck( + Constants.HealthCheck.Id, + Constants.HealthCheck.Name, + Description = Constants.HealthCheck.Description, + Group = Constants.HealthCheck.Group)] +public class ReadingTimeHealthCheck : HealthCheck +{ + private const int BatchSize = 300; + private const int PageSize = 100; + + private readonly IContentService _contentService; + private readonly IReadingTimeService _readingTimeService; + private readonly ILogger _logger; + + public ReadingTimeHealthCheck( + IContentService contentService, + IReadingTimeService readingTimeService, + ILogger logger) + { + _contentService = contentService; + _readingTimeService = readingTimeService; + _logger = logger; + } + + public override Task> GetStatusAsync() + { + var (total, missing) = CountContentWithMissingReadingTime(); + + if (total == 0) + { + var noContent = new HealthCheckStatus("No content types use a Reading Time property.") + { + ResultType = StatusResultType.Info + }; + return Task.FromResult>([noContent]); + } + + if (missing == 0) + { + var allGood = new HealthCheckStatus($"All {total} content items with Reading Time properties have calculated values.") + { + ResultType = StatusResultType.Success + }; + return Task.FromResult>([allGood]); + } + + var status = new HealthCheckStatus($"{missing} of {total} content items with Reading Time properties have missing values.") + { + ResultType = StatusResultType.Warning, + Actions = new List + { + new("recalculate-batch", Id) + { + Name = "Recalculate next batch", + Description = $"Recalculates reading time for the next {BatchSize} content items with missing values." + } + } + }; + + return Task.FromResult>([status]); + } + + public override async Task ExecuteActionAsync(HealthCheckAction action) + { + if (action.Alias != "recalculate-batch") + { + return new HealthCheckStatus("Unknown action.") + { + ResultType = StatusResultType.Error + }; + } + + try + { + var (processed, remaining) = await RecalculateBatch(); + + if (remaining > 0) + { + return new HealthCheckStatus($"Recalculated {processed} items. {remaining} remaining.") + { + ResultType = StatusResultType.Warning, + Actions = new List + { + new("recalculate-batch", Id) + { + Name = "Recalculate next batch", + Description = $"Recalculates reading time for the next {BatchSize} content items with missing values." + } + } + }; + } + + return new HealthCheckStatus($"Recalculated {processed} items. All items now have values.") + { + ResultType = StatusResultType.Success + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recalculating reading times"); + return new HealthCheckStatus($"Error recalculating: {ex.Message}") + { + ResultType = StatusResultType.Error + }; + } + } + + private async Task<(int processed, int remaining)> RecalculateBatch() + { + var processed = 0; + var remaining = 0; + var batchComplete = false; + var rootContent = _contentService.GetRootContent().ToList(); + + foreach (var root in rootContent) + { + if (batchComplete) + { + remaining += CountMissingInTree(root); + continue; + } + + (processed, remaining, batchComplete) = await ProcessTree(root, processed); + } + + return (processed, remaining); + } + + private async Task<(int processed, int remaining, bool batchComplete)> ProcessTree(IContent content, int processed) + { + var remaining = 0; + var batchComplete = false; + + if (!batchComplete) + { + var hasReadingTimeProperty = content.Properties + .Any(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias); + + if (hasReadingTimeProperty && IsMissingValue(content)) + { + if (processed >= BatchSize) + { + batchComplete = true; + remaining++; + } + else + { + var wasPublished = content.Published; + await _readingTimeService.CalculateAndSetReadingTime(content); + + _contentService.Save(content); + if (wasPublished) + { + _contentService.Publish(content, content.AvailableCultures.ToArray()); + } + + processed++; + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Recalculated reading time for {ContentName} (Id: {ContentId})", content.Name, content.Id); + } + } + } + } + + var page = 0; + var moreRecords = true; + while (moreRecords) + { + var children = _contentService + .GetPagedChildren(content.Id, page, PageSize, out var totalRecords) + .ToList(); + + foreach (var child in children) + { + if (batchComplete) + { + remaining += CountMissingInTree(child); + } + else + { + var result = await ProcessTree(child, processed); + processed = result.processed; + remaining += result.remaining; + batchComplete = result.batchComplete; + } + } + + page++; + moreRecords = (page + 1) * PageSize <= totalRecords; + } + + return (processed, remaining, batchComplete); + } + + private int CountMissingInTree(IContent content) + { + var missing = 0; + + var hasReadingTimeProperty = content.Properties + .Any(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias); + + if (hasReadingTimeProperty && IsMissingValue(content)) + { + missing++; + } + + var page = 0; + var moreRecords = true; + while (moreRecords) + { + var children = _contentService + .GetPagedChildren(content.Id, page, PageSize, out var totalRecords) + .ToList(); + + foreach (var child in children) + { + missing += CountMissingInTree(child); + } + + page++; + moreRecords = (page + 1) * PageSize <= totalRecords; + } + + return missing; + } + + private (int total, int missing) CountContentWithMissingReadingTime() + { + var total = 0; + var missing = 0; + + var rootContent = _contentService.GetRootContent().ToList(); + foreach (var root in rootContent) + { + CountInTree(root, ref total, ref missing); + } + + return (total, missing); + } + + private void CountInTree(IContent content, ref int total, ref int missing) + { + var readingTimeProperties = content.Properties + .Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias) + .ToList(); + + if (readingTimeProperties.Count > 0) + { + total++; + if (IsMissingValue(content)) + { + missing++; + } + } + + var page = 0; + var moreRecords = true; + while (moreRecords) + { + var children = _contentService + .GetPagedChildren(content.Id, page, PageSize, out var totalRecords) + .ToList(); + + foreach (var child in children) + { + CountInTree(child, ref total, ref missing); + } + + page++; + moreRecords = (page + 1) * PageSize <= totalRecords; + } + } + + private static bool IsMissingValue(IContent content) + { + var readingTimeProperties = content.Properties + .Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias) + .ToList(); + + return !readingTimeProperties.Any(p => + { + if (p.PropertyType.VariesByCulture()) + { + return content.AvailableCultures.Any(c => p.GetValue(c) != null); + } + + return p.GetValue() != null; + }); + } +} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Indexing/LegacyNestedContentReadingTimeValueProvider.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Indexing/LegacyNestedContentReadingTimeValueProvider.cs deleted file mode 100644 index f36cea4..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Indexing/LegacyNestedContentReadingTimeValueProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using jcdcdev.Umbraco.ReadingTime.Core.PropertyEditors; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.Services; - -namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Indexing; - -public class LegacyNestedContentReadingTimeValueProvider(IContentTypeService contentTypeService) : ReadingTimeValueProviderBase -{ - private readonly DefaultPropertyIndexValueFactory _converter = new(); - - public override bool CanConvert(IPropertyType type) => type.PropertyEditorAlias is Constants.PropertyEditors.Aliases.NestedContent; - - public override TimeSpan? GetReadingTime(IProperty property, string? culture, string? segment, IEnumerable availableCultures, ReadingTimeConfiguration config) - { - // TODO - Improve this - var contentTypeDictionary = contentTypeService.GetAll().ToDictionary(x => x.Key, x => x); - var values = _converter.GetIndexValues(property, culture, segment, true, availableCultures, contentTypeDictionary); - return ProcessIndexValues(values, config.WordsPerMinute); - } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/0.3.1/RebuildDatabase.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/0.3.1/RebuildDatabase.cs index 1b6dfd0..1841184 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/0.3.1/RebuildDatabase.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/0.3.1/RebuildDatabase.cs @@ -1,48 +1,5 @@ -using jcdcdev.Umbraco.ReadingTime.Core; -using jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; -using Microsoft.Extensions.Logging; -using NPoco; using Umbraco.Cms.Infrastructure.Migrations; -using Umbraco.Cms.Infrastructure.Persistence; namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Migrations; -public class RebuildDatabase(IMigrationContext context) : AsyncMigrationBase(context) -{ - protected override Task MigrateAsync() - { - Logger.LogInformation("Rebuilding ReadingTime database"); - if (TableExists(Constants.TableName)) - { - // Check if foreign key exists - var fak = "FK_jcdcdevReadingTime_umbracoNode_uniqueId"; - var tableName = Constants.TableName; - if (ConstraintExists(Context.Database, tableName, fak)) - { - Delete.ForeignKey(fak).OnTable(Constants.TableName).Do(); - } - - Delete.Table(Constants.TableName).Do(); - } - - Create.Table().Do(); - - return Task.CompletedTask; - } - - private static bool ConstraintExists(IUmbracoDatabase database, string tableName, string key) - { - string sql; - if (database.SqlContext.DatabaseType == DatabaseType.SQLite) - { - sql = $"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = '{key}' AND tbl_name = '{tableName}'"; - } - else - { - sql = $"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_NAME = '{key}' AND TABLE_NAME = '{tableName}'"; - } - - var count = database.ExecuteScalar(sql); - return count > 0; - } -} +public class RebuildDatabase(IMigrationContext context) : NoopMigration(context); diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/14.0.0/AddUpdateDate.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/14.0.0/AddUpdateDate.cs index 4660b45..9e115d3 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/14.0.0/AddUpdateDate.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/14.0.0/AddUpdateDate.cs @@ -1,28 +1,5 @@ -using jcdcdev.Umbraco.ReadingTime.Core; -using Microsoft.Extensions.Logging; using Umbraco.Cms.Infrastructure.Migrations; -using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Migrations; -public class AddUpdateDate(IMigrationContext context) : AsyncMigrationBase(context) -{ - protected override Task MigrateAsync() - { - Logger.LogInformation("Adding updateDate column to table {Table}", Constants.TableName); - - if (ColumnExists(Constants.TableName, "updateDate")) - { - return Task.CompletedTask; - } - - Alter.Table(Constants.TableName) - .AddColumn("updateDate") - .AsDateTime() - .NotNullable() - .WithDefault(SystemMethods.CurrentDateTime) - .Do(); - - return Task.CompletedTask; - } -} +public class AddUpdateDate(IMigrationContext context) : NoopMigration(context); diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/17.0.0/DropReadingTimeTable.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/17.0.0/DropReadingTimeTable.cs new file mode 100644 index 0000000..cb07206 --- /dev/null +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/17.0.0/DropReadingTimeTable.cs @@ -0,0 +1,58 @@ +using jcdcdev.Umbraco.ReadingTime.Core; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Persistence; + +namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Migrations; + +public class DropReadingTimeTable(IMigrationContext context) : AsyncMigrationBase(context) +{ + protected override Task MigrateAsync() + { + if (!TableExists(Constants.TableName)) + { + Logger.LogInformation("Table {TableName} does not exist, nothing to drop", Constants.TableName); + return Task.CompletedTask; + } + + Logger.LogInformation("Dropping table {TableName}", Constants.TableName); + + var foreignKeys = new[] + { + "FK_jcdcdevReadingTime_content_umbracoNode_uniqueId", + "FK_jcdcdevReadingTime_dataTypeKey_umbracoNode_uniqueId", + "FK_jcdcdevReadingTime_dataTypeId_umbracoNode_uniqueId", + "FK_jcdcdevReadingTime_umbracoNode_uniqueId" + }; + + foreach (var fk in foreignKeys) + { + if (ConstraintExists(Context.Database, Constants.TableName, fk)) + { + Delete.ForeignKey(fk).OnTable(Constants.TableName).Do(); + } + } + + Delete.Table(Constants.TableName).Do(); + + Logger.LogInformation("Table {TableName} dropped successfully", Constants.TableName); + return Task.CompletedTask; + } + + private static bool ConstraintExists(IUmbracoDatabase database, string tableName, string key) + { + string sql; + if (database.SqlContext.DatabaseType == DatabaseType.SQLite) + { + sql = $"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = '{key}' AND tbl_name = '{tableName}'"; + } + else + { + sql = $"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_NAME = '{key}' AND TABLE_NAME = '{tableName}'"; + } + + var count = database.ExecuteScalar(sql); + return count > 0; + } +} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/MigrationPlan.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/MigrationPlan.cs index ee874a6..e20f1d6 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/MigrationPlan.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Migrations/MigrationPlan.cs @@ -1,4 +1,4 @@ -using jcdcdev.Umbraco.ReadingTime.Core; +using jcdcdev.Umbraco.ReadingTime.Core; using Umbraco.Cms.Core.Packaging; using Umbraco.Cms.Infrastructure.Migrations; @@ -13,6 +13,7 @@ protected override void DefinePlan() To(); To(); To(); + To(); } private void To() where T : AsyncMigrationBase diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/IReadingTimeRepository.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/IReadingTimeRepository.cs deleted file mode 100644 index 0495ef9..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/IReadingTimeRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using jcdcdev.Umbraco.ReadingTime.Core.Models; -using Umbraco.Cms.Core.Models; - -namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; - -public interface IReadingTimeRepository -{ - Task DeleteAsync(Guid key); - Task GetOrCreate(Guid key, IDataType dataType); - Task PersistAsync(ReadingTimeDto dto); - Task Get(Guid key, int dataTypeId); - Task Get(Guid key, Guid dataTypeKey); -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimePoco.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimePoco.cs deleted file mode 100644 index 3580694..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimePoco.cs +++ /dev/null @@ -1,43 +0,0 @@ -using jcdcdev.Umbraco.ReadingTime.Core; -using NPoco; -using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; -using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; -using Umbraco.Cms.Infrastructure.Persistence.Dtos; - -namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; - -[TableName(Constants.TableName)] -[PrimaryKey("id")] -[ExplicitColumns] -public class ReadingTimePoco -{ - [Column(Name = "id")] - [PrimaryKeyColumn] - public int Id { get; set; } - - [Column(Name = "key")] - [ForeignKey(typeof(NodeDto), Column = "uniqueId", Name = "FK_jcdcdevReadingTime_content_umbracoNode_uniqueId")] - [NullSetting(NullSetting = NullSettings.NotNull)] - public Guid Key { get; set; } - - [Column(Name = "dataTypeKey")] - [ForeignKey(typeof(NodeDto), Column = "uniqueId", Name = "FK_jcdcdevReadingTime_dataTypeKey_umbracoNode_uniqueId")] - [NullSetting(NullSetting = NullSettings.NotNull)] - public Guid DataTypeKey { get; set; } - - [Column("data")] - [NullSetting(NullSetting = NullSettings.Null)] - [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] - public string? TextData { get; set; } - - [Column(Name = "dataTypeId")] - [ForeignKey(typeof(NodeDto), Column = "id", Name = "FK_jcdcdevReadingTime_dataTypeId_umbracoNode_uniqueId")] - [NullSetting(NullSetting = NullSettings.NotNull)] - public int DataTypeId { get; set; } - - [Column(Name = "updateDate")] - [Constraint(Default = SystemMethods.CurrentDateTime)] - [ComputedColumn(ComputedColumnType.ComputedOnInsert)] - [NullSetting(NullSetting = NullSettings.Null)] - public DateTime UpdateDate { get; set; } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimeRepository.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimeRepository.cs deleted file mode 100644 index c06350a..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/Persistence/ReadingTimeRepository.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Text.Json; -using jcdcdev.Umbraco.ReadingTime.Core.Models; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Infrastructure.Scoping; -using Umbraco.Extensions; - -namespace jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; - -public class ReadingTimeRepository(IScopeProvider scopeProvider) : IReadingTimeRepository -{ - public Task DeleteAsync(Guid key) - { - using var scope = scopeProvider.CreateScope(); - - var sql = scope.SqlContext - .Sql() - .Delete() - .Where(x => x.Key == key); - - var data = scope.Database.Execute(sql); - - scope.Complete(); - - return Task.FromResult(data); - } - - public async Task GetOrCreate(Guid key, IDataType dataType) - { - var dto = await Get(key, dataType.Id); - if (dto != null) - { - return dto; - } - - return new ReadingTimeDto - { - Key = key, - DataTypeId = dataType.Id, - DataTypeKey = dataType.Key, - UpdateDate = DateTime.UtcNow - }; - } - - public async Task PersistAsync(ReadingTimeDto dto) - { - var poco = new ReadingTimePoco - { - Id = dto.Id, - Key = dto.Key, - TextData = JsonSerializer.Serialize(dto.Data), - DataTypeId = dto.DataTypeId, - DataTypeKey = dto.DataTypeKey, - UpdateDate = dto.UpdateDate - }; - - using var scope = scopeProvider.CreateScope(); - - await scope.Database.SaveAsync(poco); - - scope.Complete(); - } - - public async Task Get(Guid key, int dataTypeId) - { - using var scope = scopeProvider.CreateScope(); - - var sql = scope.SqlContext.Sql() - .Select() - .From() - .Where(x => x.Key == key && x.DataTypeId == dataTypeId); - - var result = await scope.Database.FetchAsync(sql); - - scope.Complete(); - - return Map(result.FirstOrDefault()); - } - - public async Task Get(Guid key, Guid dataTypeKey) - { - using var scope = scopeProvider.CreateScope(); - - var sql = scope.SqlContext.Sql() - .Select() - .From() - .Where(x => x.Key == key && x.DataTypeKey == dataTypeKey); - - var result = await scope.Database.FetchAsync(sql); - - scope.Complete(); - - return Map(result.FirstOrDefault()); - } - - private static ReadingTimeDto? Map(ReadingTimePoco? result) - { - var record = result; - if (record == null) - { - return null; - } - - var data = new List(); - if (!record.TextData.IsNullOrWhiteSpace()) - { - var attempt = JsonSerializer.Deserialize>(record.TextData); - if (attempt != null) - { - data = attempt; - } - } - - return new ReadingTimeDto - { - Id = record.Id, - Key = record.Key, - DataTypeId = record.DataTypeId, - DataTypeKey = record.DataTypeKey, - Data = data, - UpdateDate = record.UpdateDate - }; - } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/ReadingTimeService.cs b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/ReadingTimeService.cs index 4b71325..3ee1c44 100644 --- a/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/ReadingTimeService.cs +++ b/src/jcdcdev.Umbraco.ReadingTime/Infrastructure/ReadingTimeService.cs @@ -1,8 +1,6 @@ -using jcdcdev.Umbraco.ReadingTime.Core; +using jcdcdev.Umbraco.ReadingTime.Core; using jcdcdev.Umbraco.ReadingTime.Core.Composing; -using jcdcdev.Umbraco.ReadingTime.Core.Models; using jcdcdev.Umbraco.ReadingTime.Core.PropertyEditors; -using jcdcdev.Umbraco.ReadingTime.Infrastructure.Persistence; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -10,176 +8,106 @@ namespace jcdcdev.Umbraco.ReadingTime.Infrastructure; -public class ReadingTimeService( - IContentService contentService, - ReadingTimeValueProviderCollection convertors, - IReadingTimeRepository readingTimeRepository, - IDataTypeService dataTypeService, - ILogger logger) - : IReadingTimeService +public class ReadingTimeService : IReadingTimeService { - private readonly ILogger _logger = logger; - - public async Task GetAsync(Guid key, Guid dataTypeKey) => await readingTimeRepository.Get(key, dataTypeKey); - - public async Task GetAsync(Guid key, int dataTypeId) => await readingTimeRepository.Get(key, dataTypeId); - - public async Task DeleteAsync(Guid key) + private readonly ReadingTimeValueProviderCollection _valueProviders; + private readonly IDataTypeService _dataTypeService; + private readonly ILogger _logger; + + public ReadingTimeService( + ReadingTimeValueProviderCollection valueProviders, + IDataTypeService dataTypeService, + ILogger logger) { - _logger.LogDebug("Deleting reading time for {Key}", key); - return await readingTimeRepository.DeleteAsync(key); + _valueProviders = valueProviders; + _dataTypeService = dataTypeService; + _logger = logger; } - public async Task ScanTree(int homeId) + public async Task CalculateAndSetReadingTime(IContent content) { - var content = contentService.GetById(homeId); - if (content == null) - { - _logger.LogWarning("Content with id {HomeId} not found", homeId); - return; - } - - var queue = new Queue(); - queue.Enqueue(content); - - while (queue.TryDequeue(out var current)) - { - var moreRecords = true; - var page = 0; - while (moreRecords) - { - var children = contentService - .GetPagedChildren(current.Id, page, 100, out var totalRecords) - .ToList(); - - foreach (var child in children) - { - queue.Enqueue(child); - } - - page++; - moreRecords = (page + 1) * 100 <= totalRecords; - } + var readingTimeProperties = content.Properties + .Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias) + .ToList(); - if (current.Published) - { - await Process(current); - } - } - } - - public async Task ScanAll() - { - var root = contentService.GetRootContent().ToList(); - _logger.LogInformation("Scanning {Count} root content items", root.Count); - foreach (var content in root) - { - await ScanTree(content.Id); - } - } - - public async Task Process(IContent item) - { - var props = item.Properties.Where(x => x.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias).ToList(); - if (!props.Any()) + if (readingTimeProperties.Count == 0) { return; } - _logger.LogDebug("Processing {Id}:{Item}", item.Id, item.Name); - foreach (var property in props) + foreach (var readingTimeProperty in readingTimeProperties) { - await ProcessPropertyEditor(item, property); + await ProcessReadingTimeProperty(content, readingTimeProperty); } } - private async Task ProcessPropertyEditor(IContent item, IProperty readingTimeProperty) + private async Task ProcessReadingTimeProperty(IContent content, IProperty readingTimeProperty) { - var dataType = await dataTypeService.GetAsync(readingTimeProperty.PropertyType.DataTypeKey); + var dataType = await _dataTypeService.GetAsync(readingTimeProperty.PropertyType.DataTypeKey); if (dataType == null) { - _logger.LogWarning("DataType not found for property {PropertyId}", readingTimeProperty.Id); + _logger.LogWarning("DataType not found for property {PropertyAlias}", readingTimeProperty.Alias); return; } var config = dataType.ConfigurationAs(); if (config == null) { - _logger.LogWarning("Configuration not found for property {PropertyId}", readingTimeProperty.Id); + _logger.LogWarning("Configuration not found for property {PropertyAlias}", readingTimeProperty.Alias); return; } - var dto = await readingTimeRepository.GetOrCreate(item.Key, dataType); - dto.UpdateDate = DateTime.UtcNow; - var models = new List(); var propertyType = readingTimeProperty.PropertyType; if (propertyType.VariesByCulture()) { - _logger.LogDebug("Processing culture variants for {Id}:{Item}", item.Id, item.Name); - foreach (var culture in item.AvailableCultures) + foreach (var culture in content.AvailableCultures) { - _logger.LogDebug("Processing culture {Culture}", culture); - var model = GetModel(item, culture, null, config); - models.Add(model); + var totalSeconds = CalculateTotalSeconds(content, culture, null, config); + content.SetValue(readingTimeProperty.Alias, totalSeconds, culture); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Set reading time for {ContentName} ({Culture}): {Seconds}s", + content.Name, culture, totalSeconds); + } } } - - _logger.LogDebug("Processing invariant variant for {Id}:{Item}", item.Id, item.Name); - var invariant = GetModel(item, null, null, config); - models.Add(invariant); - - var merge = dto.Data.Where(x => !models.Select(y => y?.Culture).Contains(x?.Culture)).ToList(); - if (merge.Any()) + else { - models.AddRange(merge); - _logger.LogDebug("Merging {Count} existing models", merge.Count()); + var totalSeconds = CalculateTotalSeconds(content, null, null, config); + content.SetValue(readingTimeProperty.Alias, totalSeconds); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Set reading time for {ContentName}: {Seconds}s", content.Name, totalSeconds); + } } - - dto.Data.Clear(); - dto.Data.AddRange(models); - - await readingTimeRepository.PersistAsync(dto); - } - - private ReadingTimeVariantDto GetModel(IContent item, string? culture, string? segment, ReadingTimeConfiguration config) - { - var readingTime = GetReadingTime(item, culture, segment, config); - var model = new ReadingTimeVariantDto - { - Culture = culture, - ReadingTime = readingTime - }; - - return model; } - private TimeSpan? GetReadingTime(IContent item, string? culture, string? segment, ReadingTimeConfiguration config) + private int CalculateTotalSeconds(IContent content, string? culture, string? segment, ReadingTimeConfiguration config) { var time = TimeSpan.Zero; - foreach (var property in item.Properties) + + foreach (var property in content.Properties) { - var convertor = convertors.FirstOrDefault(x => x.CanConvert(property.PropertyType)); - if (convertor == null) + if (property.PropertyType.PropertyEditorAlias == Constants.PropertyEditorAlias) { - _logger.LogDebug("No convertor found for {PropertyId}:{PropertyEditorAlias}", property.Id, property.PropertyType.PropertyEditorAlias); continue; } - _logger.LogDebug("Processing property {PropertyId}:{PropertyEditorAlias}", property.Id, property.PropertyType.PropertyEditorAlias); - - var cCulture = property.PropertyType.VariesByCulture() ? culture : null; - var cSegment = property.PropertyType.VariesBySegment() ? segment : null; - var readingTime = convertor?.GetReadingTime(property, cCulture, cSegment, item.AvailableCultures, config); - if (!readingTime.HasValue) + var provider = _valueProviders.FirstOrDefault(x => x.CanConvert(property.PropertyType)); + if (provider == null) { - _logger.LogDebug("No reading time found for {PropertyId}:{PropertyEditorAlias}", property.Id, property.PropertyType.PropertyEditorAlias); continue; } - _logger.LogDebug("Reading time found for {PropertyId}:{PropertyEditorAlias} ({Time})", property.Id, property.PropertyType.PropertyEditorAlias, readingTime.Value); - time += readingTime.Value; + var propertyCulture = property.PropertyType.VariesByCulture() ? culture : null; + var propertySegment = property.PropertyType.VariesBySegment() ? segment : null; + var readingTime = provider.GetReadingTime(property, propertyCulture, propertySegment, content.AvailableCultures, config); + if (readingTime.HasValue) + { + time += readingTime.Value; + } } - return time; + return (int)time.TotalSeconds; } } diff --git a/src/jcdcdev.Umbraco.ReadingTime/Web/ConfigApiSwaggerGenOptions.cs b/src/jcdcdev.Umbraco.ReadingTime/Web/ConfigApiSwaggerGenOptions.cs deleted file mode 100644 index 57988bd..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Web/ConfigApiSwaggerGenOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using jcdcdev.Umbraco.ReadingTime.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace jcdcdev.Umbraco.ReadingTime.Web; - -public class ConfigApiSwaggerGenOptions : IConfigureOptions -{ - public void Configure(SwaggerGenOptions options) - { - options.SwaggerDoc(Constants.Api.ApiName, - new OpenApiInfo - { - Title = Constants.Api.Title, - Version = "Latest", - Description = Constants.Api.Description - }); - } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Web/Controllers/ReadingTimeController.cs b/src/jcdcdev.Umbraco.ReadingTime/Web/Controllers/ReadingTimeController.cs deleted file mode 100644 index d20ea1d..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Web/Controllers/ReadingTimeController.cs +++ /dev/null @@ -1,52 +0,0 @@ -using jcdcdev.Umbraco.ReadingTime.Core; -using jcdcdev.Umbraco.ReadingTime.Core.Extensions; -using jcdcdev.Umbraco.ReadingTime.Core.PropertyEditors; -using jcdcdev.Umbraco.ReadingTime.Web.Models; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Attributes; -using Umbraco.Cms.Api.Common.Filters; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; - -namespace jcdcdev.Umbraco.ReadingTime.Web.Controllers; - -[ApiExplorerSettings(GroupName = Constants.Api.GroupName)] -[ReadingTimeRoute("")] -[MapToApi(Constants.Api.ApiName)] -[JsonOptionsName(global::Umbraco.Cms.Core.Constants.JsonOptionsNames.BackOffice)] -[ApiController] -[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] -[Produces("application/json")] -public class ReadingTimeController(IReadingTimeService service, IDataTypeService dataTypeService) : ControllerBase -{ - [HttpGet] - [Produces(typeof(ReadingTimeResponse))] - public async Task Get(string contentKey, string dataTypeKey, string? culture = null) - { - Guid.TryParse(contentKey, out var contentGuid); - Guid.TryParse(dataTypeKey, out var dataTypeGuid); - var readingTime = await service.GetAsync(contentGuid, dataTypeGuid); - if (readingTime == null) - { - return NoContent(); - } - - var value = readingTime.Value(culture); - if (value == null) - { - return NoContent(); - } - - var dataType = await dataTypeService.GetAsync(dataTypeGuid); - var config = dataType?.ConfigurationAs(); - if (config == null) - { - return BadRequest(); - } - - var model = new ReadingTimeResponse(value.ReadingTime.DisplayTime(config.Min, config.Max, culture), readingTime.UpdateDate); - return Ok(model); - } -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Web/Models/ReadingTimeResponse.cs b/src/jcdcdev.Umbraco.ReadingTime/Web/Models/ReadingTimeResponse.cs deleted file mode 100644 index c2dbdce..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Web/Models/ReadingTimeResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace jcdcdev.Umbraco.ReadingTime.Web.Models; - -public class ReadingTimeResponse(string readingTime, DateTime updateDate) -{ - public DateTime UpdateDate { get; } = updateDate; - public string ReadingTime { get; } = readingTime; -} diff --git a/src/jcdcdev.Umbraco.ReadingTime/Web/ReadingTimeRouteAttribute.cs b/src/jcdcdev.Umbraco.ReadingTime/Web/ReadingTimeRouteAttribute.cs deleted file mode 100644 index 694e37a..0000000 --- a/src/jcdcdev.Umbraco.ReadingTime/Web/ReadingTimeRouteAttribute.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Umbraco.Cms.Web.Common.Routing; - -namespace jcdcdev.Umbraco.ReadingTime.Web; - -public class ReadingTimeRouteAttribute(string template) : BackOfficeRouteAttribute($"ReadingTime/api/v{{version:apiVersion}}/{template.TrimStart('/')}");