diff --git a/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs index f1bcba38a..602c48198 100644 --- a/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs +++ b/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs @@ -1,4 +1,11 @@ -using Microsoft.Extensions.Configuration; +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Plugins; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs index 2b3e21598..509b12f41 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Security.Claims; +using NodaTime; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs index 3b5a3256a..740640700 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs @@ -155,6 +155,10 @@ public static class ScriptingExtensions vars.AppId = operation.App.Id; vars.AppName = operation.App.Name; vars.ContentId = operation.CommandId; + vars.Created = operation.Snapshot.Created; + vars.CreatedBy = operation.Snapshot.CreatedBy; + vars.LastModified = operation.Snapshot.LastModified; + vars.LastModifiedBy = operation.Snapshot.LastModifiedBy; vars.SchemaId = operation.Schema.Id; vars.SchemaName = operation.Schema.Name; vars.User = operation.User; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs index f6a6c0112..2f6da0870 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Google.LongRunning; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Shared; @@ -68,8 +69,12 @@ public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricher var vars = new ContentScriptVars { ContentId = content.Id, + Created = content.Created, + CreatedBy = content.CreatedBy, Data = content.Data, DataOld = default, + LastModified = content.LastModified, + LastModifiedBy = content.LastModifiedBy, Status = content.Status, StatusOld = default, }; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index c3d468b2b..4ca1d1dbf 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -150,52 +150,55 @@ public sealed class UsersController( try { var entity = await userResolver.FindByIdAsync(id, HttpContext.RequestAborted); + if (entity == null) + { + return new FileStreamResult(new MemoryStream(AvatarBytes), "image/png"); + } - if (entity != null) + if (entity.Claims.IsPictureUrlStored()) { - if (entity.Claims.IsPictureUrlStored()) + var callback = new FileCallback(async (body, range, ct) => { - var callback = new FileCallback(async (body, range, ct) => + try { - try - { - await userPictureStore.DownloadAsync(entity.Id, body, ct); - } - catch - { - await body.WriteAsync(AvatarBytes, ct); - } - }); - - return new FileCallbackResult("image/png", callback); - } + await userPictureStore.DownloadAsync(entity.Id, body, ct); + } + catch + { + await body.WriteAsync(AvatarBytes, ct); + } + }); - var httpClient = httpClientFactory.CreateClient("Users"); + return new FileCallbackResult("image/png", callback); + } - var url = entity.Claims.PictureNormalizedUrl(); + var httpClient = httpClientFactory.CreateClient("Users"); - if (!string.IsNullOrWhiteSpace(url)) - { - var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); + var url = entity.Claims.PictureNormalizedUrl(); - if (response.IsSuccessStatusCode) - { - var contentType = response.Content.Headers.ContentType?.ToString()!; - var contentStream = await response.Content.ReadAsStreamAsync(HttpContext.RequestAborted); + if (!string.IsNullOrWhiteSpace(url)) + { + var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); - var etag = response.Headers.ETag; + if (response.IsSuccessStatusCode) + { + var contentType = response.Content.Headers.ContentType?.ToString()!; + var contentStream = await response.Content.ReadAsStreamAsync(HttpContext.RequestAborted); - var result = new FileStreamResult(contentStream, contentType); + var etag = response.Headers.ETag; - if (!string.IsNullOrWhiteSpace(etag?.Tag)) - { - result.EntityTag = new EntityTagHeaderValue(etag.Tag, etag.IsWeak); - } + var result = new FileStreamResult(contentStream, contentType); - return result; + if (!string.IsNullOrWhiteSpace(etag?.Tag)) + { + result.EntityTag = new EntityTagHeaderValue(etag.Tag, etag.IsWeak); } + + return result; } } + + return new FileStreamResult(new MemoryStream(AvatarBytes), "image/png"); } catch (Exception ex) { diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index d2296b15b..63ace2ec5 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -123,7 +123,7 @@ public sealed class ProfileController( [HttpPost] [Route("account/profile/upload-picture/")] - public Task UploadPicture(List files) + public Task UploadPicture([FromForm(Name = "file")] List files) { return MakeChangeAsync((id, ct) => UpdatePictureAsync(files, id, ct), T.Get("users.profile.uploadPictureDone"), None.Value); @@ -148,23 +148,41 @@ public sealed class ProfileController( private async Task UpdatePictureAsync(List files, string id, CancellationToken ct) { - if (files.Count != 1) - { - throw new ValidationException(T.Get("validation.onlyOneFile")); - } - var update = new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore, }; + await UploadResizedAsync(files, id, ct); await userService.UpdateAsync(id, update, ct: ct); } - private async Task UploadResizedAsync(IAssetFile file, string id, + private async Task UploadResizedAsync(List files, string id, CancellationToken ct) { - await using var assetResized = TempAssetFile.Create(file); + if (files.Count != 1) + { + throw new ValidationException(T.Get("validation.onlyOneFile")); + } + + var file = files[0]; + if (string.IsNullOrWhiteSpace(file.ContentType)) + { + throw new ValidationException(T.Get("common.httpContentTypeNotDefined")); + } + + if (string.IsNullOrWhiteSpace(file.FileName)) + { + throw new ValidationException(T.Get("common.httpFileNameNotDefined")); + } + + var assetFile = new DelegateAssetFile( + file.FileName, + file.ContentType, + file.Length, + file.OpenReadStream); + + await using var assetResized = TempAssetFile.Create(assetFile); var resizeOptions = new ResizeOptions { @@ -174,13 +192,9 @@ public sealed class ProfileController( try { - await using (var originalStream = file.OpenRead()) - { - await using (var resizeStream = assetResized.OpenWrite()) - { - await assetGenerator.CreateThumbnailAsync(originalStream, file.MimeType, resizeStream, resizeOptions, ct); - } - } + await using var streamOriginal = assetFile.OpenRead(); + await using var streamResized = assetResized.OpenWrite(); + await assetGenerator.CreateThumbnailAsync(streamOriginal, assetFile.MimeType, streamResized, resizeOptions, ct); } catch { diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml index 6bd497401..a77f773ae 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml @@ -24,7 +24,7 @@
- +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7fe2f97d5..27f950715 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@graphiql/toolkit": "^0.11.3", "@iharbeck/ngx-virtual-scroller": "^19.0.1", "@lithiumjs/angular": "^8.0.1", + "@sindresorhus/slugify": "^3.0.0", "ace-builds": "^1.42.0", "angular-gridster2": "19.0.0", "bootstrap": "5.3.6", @@ -49,7 +50,6 @@ "react": "18.3.1", "react-dom": "18.3.1", "rxjs": "7.8.2", - "slugify": "1.6.6", "textarea-caret": "github:component/textarea-caret-position", "tslib": "2.8.1", "tui-calendar": "^1.15.3", @@ -8995,6 +8995,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sindresorhus/slugify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz", + "integrity": "sha512-SCrKh1zS96q+CuH5GumHcyQEVPsM4Ve8oE0E6tw7AAhGq50K8ojbTUOQnX/j9Mhcv/AXiIsbCfquovyGOo5fGw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^2.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-2.0.0.tgz", + "integrity": "sha512-lRx63oCHxeJ90DqIgmbxH1PQmiBDY1wVaLzB4hK0d/xS5BrG1iZO3HdCJS/DQJk6GJ8xHDev8OMI7iGxvE1ZUA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -14698,6 +14726,18 @@ "dev": true, "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -22783,14 +22823,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 89584741c..be15b4773 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@graphiql/toolkit": "^0.11.3", "@iharbeck/ngx-virtual-scroller": "^19.0.1", "@lithiumjs/angular": "^8.0.1", + "@sindresorhus/slugify": "^3.0.0", "ace-builds": "^1.42.0", "angular-gridster2": "19.0.0", "bootstrap": "5.3.6", @@ -56,7 +57,6 @@ "react": "18.3.1", "react-dom": "18.3.1", "rxjs": "7.8.2", - "slugify": "1.6.6", "textarea-caret": "github:component/textarea-caret-position", "tslib": "2.8.1", "tui-calendar": "^1.15.3", diff --git a/frontend/src/app/features/schemas/pages/schemas/schema-form.component.html b/frontend/src/app/features/schemas/pages/schemas/schema-form.component.html index 0e097f724..f94c36e9e 100644 --- a/frontend/src/app/features/schemas/pages/schemas/schema-form.component.html +++ b/frontend/src/app/features/schemas/pages/schemas/schema-form.component.html @@ -128,7 +128,7 @@ } } } @else if (selectedTab === 1) { - + } @else if (selectedTab === 2) {
diff --git a/frontend/src/app/framework/angular/forms/editors/date-time-editor.stories.ts b/frontend/src/app/framework/angular/forms/editors/date-time-editor.stories.ts index 7a0356551..c0df55ea1 100644 --- a/frontend/src/app/framework/angular/forms/editors/date-time-editor.stories.ts +++ b/frontend/src/app/framework/angular/forms/editors/date-time-editor.stories.ts @@ -59,16 +59,21 @@ export default { render: args => ({ props: args, template: ` - - +
+ + +
+ ngModel: {{ ngModel }} +
+
`, }), decorators: [ diff --git a/frontend/src/app/framework/angular/forms/transform-input.directive.ts b/frontend/src/app/framework/angular/forms/transform-input.directive.ts index 5211de271..d764c0dee 100644 --- a/frontend/src/app/framework/angular/forms/transform-input.directive.ts +++ b/frontend/src/app/framework/angular/forms/transform-input.directive.ts @@ -7,15 +7,13 @@ import { Directive, ElementRef, forwardRef, HostListener, Input, Renderer2 } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import slugify from 'slugify'; -import { Types } from '@app/framework/internal'; +import { generateSlug, Types } from '@app/framework/internal'; type Transform = (value: string) => string; export const TransformNoop: Transform = value => value; export const TransformLowerCase: Transform = value => value.toLowerCase(); -export const TransformSlugify: Transform = value => slugify(value, { lower: true, trim: false }); -export const TransformSlugifyCased: Transform = value => slugify(value, { lower: false, trim: false }); +export const TransformSlugify: Transform = value => generateSlug(value, { preserveTrailingDash: true }); export const TransformUpperCase: Transform = value => value.toUpperCase(); export const SQX_TRANSFORM_INPUT_VALUE_ACCESSOR: any = { @@ -40,8 +38,6 @@ export class TransformInputDirective implements ControlValueAccessor { this.transformer = TransformLowerCase; } else if (value === 'Slugify') { this.transform = TransformSlugify; - } else if (value === 'SlugifyCased') { - this.transform = TransformSlugifyCased; } else if (value === 'UpperCase') { this.transform = TransformUpperCase; } diff --git a/frontend/src/app/framework/internal.ts b/frontend/src/app/framework/internal.ts index 432cf72d2..689e80566 100644 --- a/frontend/src/app/framework/internal.ts +++ b/frontend/src/app/framework/internal.ts @@ -42,6 +42,7 @@ export * from './utils/math-helper'; export * from './utils/modal-view'; export * from './utils/picasso'; export * from './utils/rxjs-extensions'; +export * from './utils/slug'; export * from './utils/string-helper'; export * from './utils/tag-values'; export * from './utils/text-measurer'; diff --git a/frontend/src/app/framework/utils/markdown-transform.ts b/frontend/src/app/framework/utils/markdown-transform.ts index 04446e5d4..cdae0d545 100644 --- a/frontend/src/app/framework/utils/markdown-transform.ts +++ b/frontend/src/app/framework/utils/markdown-transform.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import slugify from 'slugify'; import { MathHelper } from './math-helper'; +import { generateSlug } from './slug'; const IMAGE_REGEX = /!\[(?[^\]]*)\]\((?.*?)([\s]["\s]*(?[^")]*)["\s]*)?\)/; const IMAGES_REGEX = /!\[(?[^\]]*)\]\((?.*?)([\s]["\s]*(?[^")]*)["\s]*)?\)/g; @@ -74,7 +74,7 @@ const IMAGE_EXTENSIONS = ['.avif', '.jpeg', '.jpg', '.png', '.webp']; function toImage(image: { url: string; name?: string; alt?: string }): MarkdownImage { let name = image.name || image.alt || 'image'; - name = slugify(name, { lower: true, trim: true }); + name = generateSlug(name); if (!IMAGE_EXTENSIONS.find(ex => name.endsWith(ex))) { name += '.webp'; diff --git a/frontend/src/app/framework/utils/slug.spec.ts b/frontend/src/app/framework/utils/slug.spec.ts new file mode 100644 index 000000000..589ac6549 --- /dev/null +++ b/frontend/src/app/framework/utils/slug.spec.ts @@ -0,0 +1,87 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { generateSlug } from './slug'; + +describe('generateSlug', () => { + describe('Should replace special characters with separator', () => { + const cases: Array<[string, string, string]> = [ + ['Hello World', '-', 'hello-world'], + ['Hello/World', '-', 'hello-world'], + ['Hello World', '_', 'hello_world'], + ['Hello/World', '_', 'hello_world'], + ['Hello World ', '_', 'hello_world'], + ['Hello World-', '_', 'hello_world'], + ['Hello/World_', '_', 'hello_world'], + ]; + + cases.forEach(([input, separator, expected]) => { + it(`slugifies "${input}" with separator "${separator}" to "${expected}"`, () => { + expect(generateSlug(input, { separator })).toEqual(expected); + }); + }); + }); + + describe('Should replace multi-char diacritics', () => { + const cases: Array<[string, string]> = [ + ['ö', 'oe'], + ['ü', 'ue'], + ['ä', 'ae'], + ]; + + cases.forEach(([input, expected]) => { + it(`slugifies "${input}" to "${expected}"`, () => { + expect(generateSlug(input)).toEqual(expected); + }); + }); + }); + + describe('Should not replace multi-char diacritics', () => { + const cases: Array<[string, string]> = [ + ['ö', 'o'], + ['ü', 'u'], + ['ä', 'a'], + ]; + + cases.forEach(([input, expected]) => { + it(`slugifies "${input}" to "${expected}" with singleCharDiacritic=true`, () => { + expect(generateSlug(input, { singleCharDiacritic: true })).toEqual(expected); + }); + }); + }); + + describe('Should replace single-char diacritics', () => { + const cases: Array<[string, string]> = [ + ['Físh', 'fish'], + ['źish', 'zish'], + ['żish', 'zish'], + ['fórm', 'form'], + ['fòrm', 'form'], + ['fårt', 'fart'], + ]; + + cases.forEach(([input, expected]) => { + it(`slugifies "${input}" to "${expected}"`, () => { + expect(generateSlug(input)).toEqual(expected); + }); + }); + }); + + describe('Should keep characters', () => { + const cases: Array<[string, string, string]> = [ + ['Hello my&World ', '_', 'hello_my&world'], + ['Hello my&World-', '_', 'hello_my&world'], + ['Hello my/World_', '_', 'hello_my/world'], + ]; + + cases.forEach(([input, separator, expected]) => { + it(`slugifies "${input}" with separator "${separator}" keeping chars`, () => { + expect(generateSlug(input, { allowed: ['&', '/'], separator })).toEqual(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/framework/utils/slug.ts b/frontend/src/app/framework/utils/slug.ts new file mode 100644 index 000000000..d5314c956 --- /dev/null +++ b/frontend/src/app/framework/utils/slug.ts @@ -0,0 +1,540 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import slugify from '@sindresorhus/slugify'; + +const DIACRITICS: Record = { + '$': 'dollar', + '%': 'percent', + '&': 'and', + '<': 'less', + '>': 'greater', + '|': 'or', + '¢': 'cent', + '£': 'pound', + '¤': 'currency', + '¥': 'yen', + '©': '(c)', + 'ª': 'a', + '®': '(r)', + 'º': 'o', + 'À': 'A', + 'Á': 'A', + 'Â': 'A', + 'Ã': 'A', + 'Ä': 'AE', + 'Å': 'A', + 'Æ': 'AE', + 'Ç': 'C', + 'Ə': 'E', + 'È': 'E', + 'É': 'E', + 'Ê': 'E', + 'Ë': 'E', + 'Ì': 'I', + 'Í': 'I', + 'Î': 'I', + 'Ï': 'I', + 'Ð': 'D', + 'Ñ': 'N', + 'Ò': 'O', + 'Ó': 'O', + 'Ô': 'O', + 'Õ': 'O', + 'Ö': 'OE', + 'Ø': 'O', + 'Ù': 'U', + 'Ú': 'U', + 'Û': 'U', + 'Ü': 'UE', + 'Ý': 'Y', + 'Þ': 'TH', + 'ß': 'ss', + 'à': 'a', + 'á': 'a', + 'â': 'a', + 'ã': 'a', + 'ä': 'ae', + 'å': 'a', + 'æ': 'ae', + 'ç': 'c', + 'ə': 'e', + 'è': 'e', + 'é': 'e', + 'ê': 'e', + 'ë': 'e', + 'ì': 'i', + 'í': 'i', + 'î': 'i', + 'ï': 'i', + 'ð': 'd', + 'ñ': 'n', + 'ò': 'o', + 'ó': 'o', + 'ô': 'o', + 'õ': 'o', + 'ö': 'oe', + 'ø': 'o', + 'ù': 'u', + 'ú': 'u', + 'û': 'u', + 'ü': 'ue', + 'ý': 'y', + 'þ': 'th', + 'ÿ': 'y', + 'Ā': 'A', + 'ā': 'a', + 'Ă': 'A', + 'ă': 'a', + 'Ą': 'A', + 'ą': 'a', + 'Ć': 'C', + 'ć': 'c', + 'Č': 'C', + 'č': 'c', + 'Ď': 'D', + 'ď': 'd', + 'Đ': 'DJ', + 'đ': 'dj', + 'Ē': 'E', + 'ē': 'e', + 'Ė': 'E', + 'ė': 'e', + 'Ę': 'e', + 'ę': 'e', + 'Ě': 'E', + 'ě': 'e', + 'Ğ': 'G', + 'ğ': 'g', + 'Ģ': 'G', + 'ģ': 'g', + 'Ĩ': 'I', + 'ĩ': 'i', + 'Ī': 'i', + 'ī': 'i', + 'Į': 'I', + 'į': 'i', + 'İ': 'I', + 'ı': 'i', + 'Ķ': 'k', + 'ķ': 'k', + 'Ļ': 'L', + 'ļ': 'l', + 'Ľ': 'L', + 'ľ': 'l', + 'Ł': 'L', + 'ł': 'l', + 'Ń': 'N', + 'ń': 'n', + 'Ņ': 'N', + 'ņ': 'n', + 'Ň': 'N', + 'ň': 'n', + 'Ő': 'O', + 'ő': 'o', + 'Œ': 'OE', + 'œ': 'oe', + 'Ŕ': 'R', + 'ŕ': 'r', + 'Ř': 'R', + 'ř': 'r', + 'Ś': 'S', + 'ś': 's', + 'Ş': 'S', + 'ş': 's', + 'Š': 'S', + 'š': 's', + 'Ţ': 'T', + 'ţ': 't', + 'Ť': 'T', + 'ť': 't', + 'Ũ': 'U', + 'ũ': 'u', + 'Ū': 'u', + 'ū': 'u', + 'Ů': 'U', + 'ů': 'u', + 'Ű': 'U', + 'ű': 'u', + 'Ų': 'U', + 'ų': 'u', + 'Ź': 'Z', + 'ź': 'z', + 'Ż': 'Z', + 'ż': 'z', + 'Ž': 'Z', + 'ž': 'z', + 'ƒ': 'f', + 'Ơ': 'O', + 'ơ': 'o', + 'Ư': 'U', + 'ư': 'u', + 'Lj': 'LJ', + 'lj': 'lj', + 'Nj': 'NJ', + 'nj': 'nj', + 'Ș': 'S', + 'ș': 's', + 'Ț': 'T', + 'ț': 't', + '˚': 'o', + 'Ά': 'A', + 'Έ': 'E', + 'Ή': 'H', + 'Ί': 'I', + 'Ό': 'O', + 'Ύ': 'Y', + 'Ώ': 'W', + 'ΐ': 'i', + 'Α': 'A', + 'Β': 'B', + 'Γ': 'G', + 'Δ': 'D', + 'Ε': 'E', + 'Ζ': 'Z', + 'Η': 'H', + 'Θ': '8', + 'Ι': 'I', + 'Κ': 'K', + 'Λ': 'L', + 'Μ': 'M', + 'Ν': 'N', + 'Ξ': '3', + 'Ο': 'O', + 'Π': 'P', + 'Ρ': 'R', + 'Σ': 'S', + 'Τ': 'T', + 'Υ': 'Y', + 'Φ': 'F', + 'Χ': 'X', + 'Ψ': 'PS', + 'Ω': 'W', + 'Ϊ': 'I', + 'Ϋ': 'Y', + 'ά': 'a', + 'έ': 'e', + 'ή': 'h', + 'ί': 'i', + 'ΰ': 'y', + 'α': 'a', + 'β': 'b', + 'γ': 'g', + 'δ': 'd', + 'ε': 'e', + 'ζ': 'z', + 'η': 'h', + 'θ': '8', + 'ι': 'i', + 'κ': 'k', + 'λ': 'l', + 'μ': 'm', + 'ν': 'n', + 'ξ': '3', + 'ο': 'o', + 'π': 'p', + 'ρ': 'r', + 'ς': 's', + 'σ': 's', + 'τ': 't', + 'υ': 'y', + 'φ': 'f', + 'χ': 'x', + 'ψ': 'ps', + 'ω': 'w', + 'ϊ': 'i', + 'ϋ': 'y', + 'ό': 'o', + 'ύ': 'y', + 'ώ': 'w', + 'Ё': 'Yo', + 'Ђ': 'DJ', + 'Є': 'Ye', + 'І': 'I', + 'Ї': 'Yi', + 'Ј': 'J', + 'Љ': 'LJ', + 'Њ': 'NJ', + 'Ћ': 'C', + 'Џ': 'DZ', + 'А': 'A', + 'Б': 'B', + 'В': 'V', + 'Г': 'G', + 'Д': 'D', + 'Е': 'E', + 'Ж': 'Zh', + 'З': 'Z', + 'И': 'I', + 'Й': 'J', + 'К': 'K', + 'Л': 'L', + 'М': 'M', + 'Н': 'N', + 'О': 'O', + 'П': 'P', + 'Р': 'R', + 'С': 'S', + 'Т': 'T', + 'У': 'U', + 'Ф': 'F', + 'Х': 'H', + 'Ц': 'C', + 'Ч': 'Ch', + 'Ш': 'Sh', + 'Щ': 'Sh', + 'Ъ': 'U', + 'Ы': 'Y', + 'Ь': 'b', + 'Э': 'E', + 'Ю': 'Yu', + 'Я': 'Ya', + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'е': 'e', + 'ж': 'zh', + 'з': 'z', + 'и': 'i', + 'й': 'j', + 'к': 'k', + 'л': 'l', + 'м': 'm', + 'н': 'n', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'у': 'u', + 'ф': 'f', + 'х': 'h', + 'ц': 'c', + 'ч': 'ch', + 'ш': 'sh', + 'щ': 'sh', + 'ъ': 'u', + 'ы': 'y', + 'ь': 's', + 'э': 'e', + 'ю': 'yu', + 'я': 'ya', + 'ё': 'yo', + 'ђ': 'dj', + 'є': 'ye', + 'і': 'i', + 'ї': 'yi', + 'ј': 'j', + 'љ': 'lj', + 'њ': 'nj', + 'ћ': 'c', + 'џ': 'dz', + 'Ґ': 'G', + 'ґ': 'g', + '฿': 'baht', + 'ა': 'a', + 'ბ': 'b', + 'გ': 'g', + 'დ': 'd', + 'ე': 'e', + 'ვ': 'v', + 'ზ': 'z', + 'თ': 't', + 'ი': 'i', + 'კ': 'k', + 'ლ': 'l', + 'მ': 'm', + 'ნ': 'n', + 'ო': 'o', + 'პ': 'p', + 'ჟ': 'zh', + 'რ': 'r', + 'ს': 's', + 'ტ': 't', + 'უ': 'u', + 'ფ': 'f', + 'ქ': 'k', + 'ღ': 'gh', + 'ყ': 'q', + 'შ': 'sh', + 'ჩ': 'ch', + 'ც': 'ts', + 'ძ': 'dz', + 'წ': 'ts', + 'ჭ': 'ch', + 'ხ': 'kh', + 'ჯ': 'j', + 'ჰ': 'h', + 'ẞ': 'SS', + 'Ạ': 'A', + 'ạ': 'a', + 'Ả': 'A', + 'ả': 'a', + 'Ấ': 'A', + 'ấ': 'a', + 'Ầ': 'A', + 'ầ': 'a', + 'Ẩ': 'A', + 'ẩ': 'a', + 'Ẫ': 'A', + 'ẫ': 'a', + 'Ậ': 'A', + 'ậ': 'a', + 'Ắ': 'A', + 'ắ': 'a', + 'Ằ': 'A', + 'ằ': 'a', + 'Ẳ': 'A', + 'ẳ': 'a', + 'Ẵ': 'A', + 'ẵ': 'a', + 'Ặ': 'A', + 'ặ': 'a', + 'Ẹ': 'E', + 'ẹ': 'e', + 'Ẻ': 'E', + 'ẻ': 'e', + 'Ẽ': 'E', + 'ẽ': 'e', + 'Ế': 'E', + 'ế': 'e', + 'Ề': 'E', + 'ề': 'e', + 'Ể': 'E', + 'ể': 'e', + 'Ễ': 'E', + 'ễ': 'e', + 'Ệ': 'E', + 'ệ': 'e', + 'Ỉ': 'I', + 'ỉ': 'i', + 'Ị': 'I', + 'ị': 'i', + 'Ọ': 'O', + 'ọ': 'o', + 'Ỏ': 'O', + 'ỏ': 'o', + 'Ố': 'O', + 'ố': 'o', + 'Ồ': 'O', + 'ồ': 'o', + 'Ổ': 'O', + 'ổ': 'o', + 'Ỗ': 'O', + 'ỗ': 'o', + 'Ộ': 'O', + 'ộ': 'o', + 'Ớ': 'O', + 'ớ': 'o', + 'Ờ': 'O', + 'ờ': 'o', + 'Ở': 'O', + 'ở': 'o', + 'Ỡ': 'O', + 'ỡ': 'o', + 'Ợ': 'O', + 'ợ': 'o', + 'Ụ': 'U', + 'ụ': 'u', + 'Ủ': 'U', + 'ủ': 'u', + 'Ứ': 'U', + 'ứ': 'u', + 'Ừ': 'U', + 'ừ': 'u', + 'Ử': 'U', + 'ử': 'u', + 'Ữ': 'U', + 'ữ': 'u', + 'Ự': 'U', + 'ự': 'u', + 'Ỳ': 'Y', + 'ỳ': 'y', + 'Ỵ': 'Y', + 'ỵ': 'y', + 'Ỷ': 'Y', + 'ỷ': 'y', + 'Ỹ': 'Y', + 'ỹ': 'y', + '‘': '\'', + '’': '\'', + '“': '\\\"', + '”': '\\\"', + '†': '+', + '•': '*', + '…': '...', + '₠': 'ecu', + '₢': 'cruzeiro', + '₣': 'french franc', + '₤': 'lira', + '₥': 'mill', + '₦': 'naira', + '₧': 'peseta', + '₨': 'rupee', + '₩': 'won', + '₪': 'new shequel', + '₫': 'dong', + '€': 'euro', + '₭': 'kip', + '₮': 'tugrik', + '₯': 'drachma', + '₰': 'penny', + '₱': 'peso', + '₲': 'guarani', + '₳': 'austral', + '₴': 'hryvnia', + '₵': 'cedi', + '₹': 'indian rupee', + '₽': 'russian ruble', + '₿': 'bitcoin', + '℠': 'sm', + '™': 'tm', + '∂': 'd', + '∆': 'delta', + '∑': 'sum', + '∞': 'infinity', + '♥': 'love', + '元': 'yuan', + '円': 'yen', + '﷼': 'rial', +}; + +export const LOWER_CASE_DIACRITICS: ReadonlyArray<[string, string]> = + Object.entries(DIACRITICS).map(([key, value]) => [key, value.toLowerCase()]); + +export const LOWER_CASE_SINGLE_DIACRITICS: ReadonlyArray<[string, string]> = + Object.entries(DIACRITICS).map(([key, value]) => [key, value.toLowerCase()[0]]); + +export function generateSlug(input: string, options?: { separator?: string; singleCharDiacritic?: boolean; allowed?: string[]; preserveTrailingDash?: boolean }) { + if (!input) { + return input?.trim(); + } + + const separator = options?.separator || '-'; + + let customReplacements = + options?.singleCharDiacritic ? + LOWER_CASE_SINGLE_DIACRITICS : + LOWER_CASE_DIACRITICS; + + if (options?.allowed) { + customReplacements = [...customReplacements]; + for (const key of options.allowed) { + customReplacements = customReplacements.filter(x => x[0] !== key); + } + } + + return slugify(input, { + customReplacements, + preserveCharacters: options?.allowed || [], + preserveTrailingDash: options?.preserveTrailingDash, + separator, + transliterate: false, + }); +} \ No newline at end of file diff --git a/frontend/src/app/shared/state/assets.forms.spec.ts b/frontend/src/app/shared/state/assets.forms.spec.ts index 849461695..01e74e3a9 100644 --- a/frontend/src/app/shared/state/assets.forms.spec.ts +++ b/frontend/src/app/shared/state/assets.forms.spec.ts @@ -58,6 +58,16 @@ describe('AnnotateAssetForm', () => { expect(slug).toBe('my-new-file.png'); }); + it('should create slug from file with dots', () => { + form.form.get('fileName')!.setValue('My.New.File'); + + form.generateSlug(asset); + + const slug = form.form.get('slug')!.value; + + expect(slug).toBe('my.new.file.png'); + }); + it('should convert metadata if loading', () => { form.load(asset); diff --git a/frontend/src/app/shared/state/assets.forms.ts b/frontend/src/app/shared/state/assets.forms.ts index bbdfc99b1..614480fc4 100644 --- a/frontend/src/app/shared/state/assets.forms.ts +++ b/frontend/src/app/shared/state/assets.forms.ts @@ -6,8 +6,7 @@ */ import { UntypedFormControl, Validators } from '@angular/forms'; -import slugify from 'slugify'; -import { ExtendedFormGroup, Form, TemplatedFormArray, Types } from '@app/framework'; +import { ExtendedFormGroup, Form, generateSlug, TemplatedFormArray, Types } from '@app/framework'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, MoveAssetDto, RenameAssetFolderDto, RenameTagDto, UpdateAssetScriptsDto } from '../model'; export class AnnotateAssetForm extends Form { @@ -144,7 +143,7 @@ export class AnnotateAssetForm extends Form