@streamscloud/kit 0.9.13 → 0.9.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/core/data-loaders/cursor-data-loader-with-search.svelte.d.ts +3 -1
  2. package/dist/core/data-loaders/cursor-data-loader-with-search.svelte.js +37 -16
  3. package/dist/core/data-loaders/cursor-data-loader.svelte.d.ts +3 -1
  4. package/dist/core/data-loaders/cursor-data-loader.svelte.js +34 -13
  5. package/dist/core/files/file-types.d.ts +4 -0
  6. package/dist/core/files/file-types.js +24 -9
  7. package/dist/core/files/file-validation-localization.d.ts +12 -0
  8. package/dist/core/files/file-validation-localization.js +75 -0
  9. package/dist/core/files/file-validation-rule-sets.d.ts +8 -0
  10. package/dist/core/files/file-validation-rule-sets.js +24 -0
  11. package/dist/core/files/file-validation-rules.d.ts +20 -11
  12. package/dist/core/files/file-validation-rules.js +74 -45
  13. package/dist/core/files/file-validation-types.d.ts +9 -2
  14. package/dist/core/files/file-validator.d.ts +3 -3
  15. package/dist/core/files/file-validator.js +16 -2
  16. package/dist/core/files/index.d.ts +2 -1
  17. package/dist/core/files/index.js +1 -0
  18. package/dist/core/utils/number-helper.js +1 -1
  19. package/dist/ui/collection-list/cmp.collection-list.svelte +242 -0
  20. package/dist/ui/collection-list/cmp.collection-list.svelte.d.ts +64 -0
  21. package/dist/ui/collection-list/collection-list-localization.d.ts +4 -0
  22. package/dist/ui/collection-list/collection-list-localization.js +19 -0
  23. package/dist/ui/collection-list/index.d.ts +2 -0
  24. package/dist/ui/collection-list/index.js +2 -0
  25. package/dist/ui/collection-list/types.d.ts +28 -0
  26. package/dist/ui/collection-list/types.js +20 -0
  27. package/dist/ui/file-uploader/cmp.file-uploader.svelte +2 -7
  28. package/dist/ui/file-uploader/cmp.file-uploader.svelte.d.ts +8 -5
  29. package/dist/ui/open-file-button/cmp.open-file-button.svelte +2 -6
  30. package/dist/ui/open-file-button/cmp.open-file-button.svelte.d.ts +7 -4
  31. package/dist/ui/select/multiselect-base.svelte +1 -0
  32. package/dist/ui/select/select-core.svelte.d.ts +2 -0
  33. package/dist/ui/select/select-core.svelte.js +6 -2
  34. package/dist/ui/select/select-listbox.svelte +31 -16
  35. package/dist/ui/select/select-listbox.svelte.d.ts +1 -1
  36. package/dist/ui/tooltip/cmp.tooltip.svelte +7 -0
  37. package/dist/ui/video/cmp.video.svelte +17 -7
  38. package/package.json +9 -1
@@ -5,7 +5,8 @@ export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
5
5
  private continuationToken;
6
6
  private _searchString;
7
7
  private loadPage;
8
- private deferred;
8
+ private pending;
9
+ private generation;
9
10
  private searchStringMinLength;
10
11
  constructor(init: {
11
12
  loadPage: (continuationToken: ContinuationToken, searchString: string) => Promise<CursorResult<T> | null>;
@@ -15,5 +16,6 @@ export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
15
16
  loadMore: () => Promise<T[]>;
16
17
  reset(): Promise<void>;
17
18
  updateSearchString: (searchString: string | null) => void;
19
+ private runLoad;
18
20
  private isSearchStringEffective;
19
21
  }
@@ -1,11 +1,12 @@
1
- import { ContinuationToken, Deferred } from '..';
1
+ import { ContinuationToken } from '..';
2
2
  import { Utils } from '../utils';
3
3
  export class CursorDataLoaderWithSearch {
4
4
  items = $state.raw([]);
5
5
  continuationToken = $state.raw(ContinuationToken.init());
6
6
  _searchString = $state.raw('');
7
7
  loadPage;
8
- deferred = null;
8
+ pending = null;
9
+ generation = 0;
9
10
  searchStringMinLength = 1;
10
11
  constructor(init) {
11
12
  this.loadPage = init.loadPage;
@@ -18,25 +19,27 @@ export class CursorDataLoaderWithSearch {
18
19
  return this._searchString;
19
20
  }
20
21
  loadMore = async () => {
21
- if (this.deferred) {
22
- return this.deferred.promise;
22
+ if (this.pending) {
23
+ return this.pending;
23
24
  }
24
25
  if (!this.continuationToken.canLoadMore) {
25
26
  return [];
26
27
  }
27
- this.deferred = new Deferred();
28
- const search = this.isSearchStringEffective(this._searchString) ? this._searchString : '';
29
- let result = await this.loadPage(this.continuationToken, search);
30
- if (!result) {
31
- result = { items: [], continuationToken: ContinuationToken.preventLoading() };
32
- }
33
- this.continuationToken = result.continuationToken;
34
- this.items = [...this.items, ...result.items];
35
- this.deferred.resolve(result.items);
36
- this.deferred = null;
37
- return result.items;
28
+ const pending = this.runLoad();
29
+ this.pending = pending;
30
+ try {
31
+ return await pending;
32
+ }
33
+ finally {
34
+ // a concurrent reset() may have installed a newer load — leave that one alone
35
+ if (this.pending === pending) {
36
+ this.pending = null;
37
+ }
38
+ }
38
39
  };
39
40
  async reset() {
41
+ this.generation++;
42
+ this.pending = null;
40
43
  this.items = [];
41
44
  this.continuationToken = ContinuationToken.init();
42
45
  await this.loadMore();
@@ -50,7 +53,25 @@ export class CursorDataLoaderWithSearch {
50
53
  const isEffective = this.isSearchStringEffective(newValue);
51
54
  this._searchString = newValue;
52
55
  if (wasEffective || isEffective) {
53
- this.reset();
56
+ void this.reset();
57
+ }
58
+ };
59
+ runLoad = async () => {
60
+ const generation = this.generation;
61
+ const search = this.isSearchStringEffective(this._searchString) ? this._searchString : '';
62
+ try {
63
+ const result = (await this.loadPage(this.continuationToken, search)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
64
+ // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
65
+ if (generation !== this.generation) {
66
+ return [];
67
+ }
68
+ this.continuationToken = result.continuationToken;
69
+ this.items = [...this.items, ...result.items];
70
+ return result.items;
71
+ }
72
+ catch (error) {
73
+ console.error('CursorDataLoaderWithSearch: failed to load page', error);
74
+ return [];
54
75
  }
55
76
  };
56
77
  isSearchStringEffective = (searchString) => searchString && searchString.length >= this.searchStringMinLength;
@@ -4,10 +4,12 @@ export declare class CursorDataLoader<T> implements IDataLoader<T> {
4
4
  items: T[];
5
5
  continuationToken: ContinuationToken;
6
6
  private loadPage;
7
- private deferred;
7
+ private pending;
8
+ private generation;
8
9
  constructor(init: {
9
10
  loadPage: (continuationToken: ContinuationToken) => Promise<CursorResult<T> | null>;
10
11
  });
11
12
  loadMore: () => Promise<T[]>;
12
13
  reset(): Promise<void>;
14
+ private runLoad;
13
15
  }
@@ -1,33 +1,54 @@
1
- import { ContinuationToken, Deferred } from '..';
1
+ import { ContinuationToken } from '..';
2
2
  export class CursorDataLoader {
3
3
  items = $state.raw([]);
4
4
  continuationToken = $state.raw(ContinuationToken.init());
5
5
  loadPage;
6
- deferred = null;
6
+ pending = null;
7
+ generation = 0;
7
8
  constructor(init) {
8
9
  this.loadPage = init.loadPage;
9
10
  }
10
11
  loadMore = async () => {
11
- if (this.deferred) {
12
- return this.deferred.promise;
12
+ if (this.pending) {
13
+ return this.pending;
13
14
  }
14
15
  if (!this.continuationToken.canLoadMore) {
15
16
  return [];
16
17
  }
17
- this.deferred = new Deferred();
18
- let result = await this.loadPage(this.continuationToken);
19
- if (!result) {
20
- result = { items: [], continuationToken: ContinuationToken.preventLoading() };
18
+ const pending = this.runLoad();
19
+ this.pending = pending;
20
+ try {
21
+ return await pending;
22
+ }
23
+ finally {
24
+ // a concurrent reset() may have installed a newer load — leave that one alone
25
+ if (this.pending === pending) {
26
+ this.pending = null;
27
+ }
21
28
  }
22
- this.continuationToken = result.continuationToken;
23
- this.items = [...this.items, ...result.items];
24
- this.deferred.resolve(result.items);
25
- this.deferred = null;
26
- return result.items;
27
29
  };
28
30
  async reset() {
31
+ this.generation++;
32
+ this.pending = null;
29
33
  this.items = [];
30
34
  this.continuationToken = ContinuationToken.init();
31
35
  await this.loadMore();
32
36
  }
37
+ runLoad = async () => {
38
+ const generation = this.generation;
39
+ try {
40
+ const result = (await this.loadPage(this.continuationToken)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
41
+ // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
42
+ if (generation !== this.generation) {
43
+ return [];
44
+ }
45
+ this.continuationToken = result.continuationToken;
46
+ this.items = [...this.items, ...result.items];
47
+ return result.items;
48
+ }
49
+ catch (error) {
50
+ console.error('CursorDataLoader: failed to load page', error);
51
+ return [];
52
+ }
53
+ };
33
54
  }
@@ -13,4 +13,8 @@ export declare class AcceptFileType {
13
13
  static readonly webAssetImageOrVideo: string;
14
14
  static readonly webAssetIcon = "image/png";
15
15
  }
16
+ /**
17
+ * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
18
+ * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
19
+ */
16
20
  export declare const matchesAcceptedFileTypes: (file: File, acceptedTypesAndExtensions: string) => boolean;
@@ -1,4 +1,3 @@
1
- import { FileHelper } from './file-helper';
2
1
  export class AcceptFileType {
3
2
  static any = '*/*';
4
3
  static image = 'image/png,image/jpeg,image/webp';
@@ -15,14 +14,30 @@ export class AcceptFileType {
15
14
  static webAssetImageOrVideo = [this.webAssetImage, this.webAssetVideo].join(',');
16
15
  static webAssetIcon = 'image/png';
17
16
  }
17
+ /**
18
+ * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
19
+ * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
20
+ */
18
21
  export const matchesAcceptedFileTypes = (file, acceptedTypesAndExtensions) => {
19
- const isValid = AcceptFileType.anySupported.split(',').includes(file.type);
20
- if (acceptedTypesAndExtensions) {
21
- const acceptanceArray = acceptedTypesAndExtensions.split(',');
22
- const extensions = acceptanceArray.filter((x) => x.startsWith('.'));
23
- const fileExt = '.' + FileHelper.getFileExtension(file.name);
24
- const fileTypes = acceptanceArray.filter((x) => !x.startsWith('.'));
25
- return (extensions.includes(fileExt) || fileTypes.includes(file.type)) && isValid;
22
+ const tokens = acceptedTypesAndExtensions
23
+ .split(',')
24
+ .map((s) => s.trim().toLowerCase())
25
+ .filter(Boolean);
26
+ if (tokens.length === 0) {
27
+ return true;
26
28
  }
27
- return isValid;
29
+ const fileType = (file.type || '').toLowerCase();
30
+ const fileName = file.name.toLowerCase();
31
+ return tokens.some((token) => {
32
+ if (token.startsWith('.')) {
33
+ return fileName.endsWith(token);
34
+ }
35
+ if (token === '*/*') {
36
+ return true;
37
+ }
38
+ if (token.endsWith('/*')) {
39
+ return fileType.startsWith(token.slice(0, -1));
40
+ }
41
+ return fileType === token;
42
+ });
28
43
  };
@@ -0,0 +1,12 @@
1
+ export declare class FileValidationLocalization {
2
+ get invalidFile(): string;
3
+ get unsupportedType(): string;
4
+ get audioOnly(): string;
5
+ get maxSize(): (size: string) => string;
6
+ get extensions(): (list: string) => string;
7
+ get maxDimensions(): (width: number, height: number) => string;
8
+ get minDimensions(): (width: number, height: number) => string;
9
+ get maxDuration(): (seconds: number) => string;
10
+ get aspectRatio(): (ratios: string) => string;
11
+ get maxBitrate(): (kbps: number) => string;
12
+ }
@@ -0,0 +1,75 @@
1
+ import { AppLocale } from '../locale';
2
+ export class FileValidationLocalization {
3
+ get invalidFile() {
4
+ return loc.invalidFile[AppLocale.current];
5
+ }
6
+ get unsupportedType() {
7
+ return loc.unsupportedType[AppLocale.current];
8
+ }
9
+ get audioOnly() {
10
+ return loc.audioOnly[AppLocale.current];
11
+ }
12
+ get maxSize() {
13
+ return loc.maxSize[AppLocale.current];
14
+ }
15
+ get extensions() {
16
+ return loc.extensions[AppLocale.current];
17
+ }
18
+ get maxDimensions() {
19
+ return loc.maxDimensions[AppLocale.current];
20
+ }
21
+ get minDimensions() {
22
+ return loc.minDimensions[AppLocale.current];
23
+ }
24
+ get maxDuration() {
25
+ return loc.maxDuration[AppLocale.current];
26
+ }
27
+ get aspectRatio() {
28
+ return loc.aspectRatio[AppLocale.current];
29
+ }
30
+ get maxBitrate() {
31
+ return loc.maxBitrate[AppLocale.current];
32
+ }
33
+ }
34
+ const loc = {
35
+ invalidFile: {
36
+ en: 'Invalid file',
37
+ no: 'Ugyldig fil'
38
+ },
39
+ unsupportedType: {
40
+ en: 'Unsupported file type',
41
+ no: 'Filtypen støttes ikke'
42
+ },
43
+ audioOnly: {
44
+ en: 'Audio files only',
45
+ no: 'Kun lydfiler'
46
+ },
47
+ maxSize: {
48
+ en: (size) => `Maximum file size: ${size}`,
49
+ no: (size) => `Maks filstørrelse: ${size}`
50
+ },
51
+ extensions: {
52
+ en: (list) => `Allowed file types: ${list}`,
53
+ no: (list) => `Tillatte filtyper: ${list}`
54
+ },
55
+ maxDimensions: {
56
+ en: (width, height) => `Maximum dimensions: ${width}×${height}px`,
57
+ no: (width, height) => `Maks oppløsning: ${width}×${height}px`
58
+ },
59
+ minDimensions: {
60
+ en: (width, height) => `Minimum dimensions: ${width}×${height}px`,
61
+ no: (width, height) => `Min oppløsning: ${width}×${height}px`
62
+ },
63
+ maxDuration: {
64
+ en: (seconds) => `Maximum duration: ${seconds}s`,
65
+ no: (seconds) => `Maks varighet: ${seconds}s`
66
+ },
67
+ aspectRatio: {
68
+ en: (ratios) => `Allowed aspect ratio: ${ratios}`,
69
+ no: (ratios) => `Tillatt sideforhold: ${ratios}`
70
+ },
71
+ maxBitrate: {
72
+ en: (kbps) => `Maximum bitrate: ${kbps} kbps`,
73
+ no: (kbps) => `Maks bitrate: ${kbps} kbps`
74
+ }
75
+ };
@@ -0,0 +1,8 @@
1
+ import type { FileValidationRule, FileValidationRuleSets } from './file-validation-types';
2
+ export declare const toRuleSets: (rules: FileValidationRule[] | FileValidationRuleSets) => FileValidationRuleSets;
3
+ /**
4
+ * Union of every rule's `accept` across all sets, de-duplicated — the native picker filter /
5
+ * drag-over hint a containing cmp shows so a file matching any set can be selected.
6
+ */
7
+ export declare const deriveAccept: (rules: FileValidationRule[] | FileValidationRuleSets) => string;
8
+ export declare const pickRuleSet: (file: File, sets: FileValidationRuleSets) => FileValidationRule[] | null;
@@ -0,0 +1,24 @@
1
+ import { matchesAcceptedFileTypes } from './file-types';
2
+ const isRuleSets = (rules) => Array.isArray(rules[0]);
3
+ export const toRuleSets = (rules) => rules.length === 0 ? [] : isRuleSets(rules) ? rules : [rules];
4
+ const acceptTokens = (set) => set
5
+ .flatMap((rule) => (rule.accept ? rule.accept.split(',') : []))
6
+ .map((token) => token.trim())
7
+ .filter(Boolean);
8
+ /**
9
+ * Union of every rule's `accept` across all sets, de-duplicated — the native picker filter /
10
+ * drag-over hint a containing cmp shows so a file matching any set can be selected.
11
+ */
12
+ export const deriveAccept = (rules) => {
13
+ const tokens = toRuleSets(rules).flatMap(acceptTokens);
14
+ return [...new Set(tokens)].join(',');
15
+ };
16
+ export const pickRuleSet = (file, sets) => {
17
+ for (const set of sets) {
18
+ const accept = acceptTokens(set).join(',');
19
+ if (!accept || matchesAcceptedFileTypes(file, accept)) {
20
+ return set;
21
+ }
22
+ }
23
+ return null;
24
+ };
@@ -1,30 +1,39 @@
1
1
  import type { FileValidationRule } from './file-validation-types';
2
2
  /**
3
- * Built-in rule factories. Each takes a constraint plus the consumer-supplied localized message
4
- * kit never ships English error strings. Returned rule is a callable function; the `Mime`
5
- * factory additionally attaches `.accept` so containing UI cmps (`FileUploader`,
6
- * `OpenFileButton`) can auto-derive the native picker filter / drag-over hint.
3
+ * Built-in rule factories. Each takes a constraint and an optional `message`; when omitted, the
4
+ * rule falls back to a localized default from `FileValidationLocalization` (kit ships no English
5
+ * literals — defaults follow `AppLocale`). The `Mime` factory additionally attaches `.accept` so
6
+ * containing UI cmps (`FileUploader`, `OpenFileButton`) can auto-derive the native picker filter /
7
+ * drag-over hint.
7
8
  *
8
9
  * @example
9
10
  * ```ts
10
11
  * const rules = [
11
- * FileRules.Mime('image/*', 'Please pick an image.'),
12
+ * FileRules.Mime('image/*'),
12
13
  * FileRules.MaxSize(5 * 1024 * 1024, 'Image must be under 5 MB.')
13
14
  * ];
14
15
  * const results = await FileValidator.validateMany(files, rules);
15
16
  * ```
16
17
  */
17
18
  export declare const FileRules: {
18
- MaxSize: (bytes: number, message: string) => FileValidationRule;
19
- Mime: (accept: string, message: string) => FileValidationRule;
19
+ AudioBitrate: (maxKbps: number, message?: string) => FileValidationRule;
20
+ AudioDuration: (maxSeconds: number, message?: string) => FileValidationRule;
21
+ AudioOnly: (message?: string) => FileValidationRule;
22
+ Extensions: (extensions: string[], message?: string) => FileValidationRule;
20
23
  ImageDimensions: (max: {
21
24
  width: number;
22
25
  height: number;
23
- }, message: string) => FileValidationRule;
26
+ }, message?: string) => FileValidationRule;
24
27
  ImageMinDimensions: (min: {
25
28
  width: number;
26
29
  height: number;
27
- }, message: string) => FileValidationRule;
28
- AudioDuration: (maxSeconds: number, message: string) => FileValidationRule;
29
- VideoDuration: (maxSeconds: number, message: string) => FileValidationRule;
30
+ }, message?: string) => FileValidationRule;
31
+ MaxSize: (bytes: number, message?: string) => FileValidationRule;
32
+ Mime: (accept: string, message?: string) => FileValidationRule;
33
+ VideoAspectRatio: (ratios: string[], message?: string, tolerancePercent?: number) => FileValidationRule;
34
+ VideoDimensions: (max: {
35
+ width: number;
36
+ height: number;
37
+ }, message?: string) => FileValidationRule;
38
+ VideoDuration: (maxSeconds: number, message?: string) => FileValidationRule;
30
39
  };
@@ -1,32 +1,7 @@
1
- /**
2
- * Permissive `accept`-style matcher. Tokens may be MIMEs (`image/*`, `application/pdf`) or
3
- * extensions (`.pdf`). Empty accept = match anything. Unlike `matchesAcceptedFileTypes` from
4
- * `./file-types`, this does NOT cross-check against an internal whitelist — only the
5
- * consumer's accept string drives the decision.
6
- */
7
- const matchesAccept = (file, accept) => {
8
- if (!accept) {
9
- return true;
10
- }
11
- const tokens = accept
12
- .split(',')
13
- .map((s) => s.trim().toLowerCase())
14
- .filter(Boolean);
15
- if (tokens.length === 0) {
16
- return true;
17
- }
18
- const fileType = (file.type || '').toLowerCase();
19
- const fileName = file.name.toLowerCase();
20
- return tokens.some((t) => {
21
- if (t.startsWith('.')) {
22
- return fileName.endsWith(t);
23
- }
24
- if (t.endsWith('/*')) {
25
- return fileType.startsWith(t.slice(0, -1));
26
- }
27
- return fileType === t;
28
- });
29
- };
1
+ import { StringHelper } from '../utils';
2
+ import { FileHelper } from './file-helper';
3
+ import { matchesAcceptedFileTypes } from './file-types';
4
+ import { FileValidationLocalization } from './file-validation-localization';
30
5
  const readImageDimensions = (file) => new Promise((resolve, reject) => {
31
6
  const url = URL.createObjectURL(file);
32
7
  const img = new Image();
@@ -54,42 +29,96 @@ const readMediaDuration = (file, kind) => new Promise((resolve, reject) => {
54
29
  };
55
30
  el.src = url;
56
31
  });
32
+ const readVideoDimensions = (file) => new Promise((resolve, reject) => {
33
+ const url = URL.createObjectURL(file);
34
+ const video = document.createElement('video');
35
+ video.preload = 'metadata';
36
+ video.onloadedmetadata = () => {
37
+ URL.revokeObjectURL(url);
38
+ resolve({ width: video.videoWidth, height: video.videoHeight });
39
+ };
40
+ video.onerror = () => {
41
+ URL.revokeObjectURL(url);
42
+ reject(new Error('Failed to read video dimensions'));
43
+ };
44
+ video.src = url;
45
+ });
46
+ const parseRatio = (ratio) => {
47
+ const [w, h] = ratio.split(':').map((part) => parseFloat(part));
48
+ return h ? w / h : NaN;
49
+ };
50
+ const formatExtensions = (extensions) => extensions.map((ext) => '.' + ext.replace(/^\./, '')).join(', ');
51
+ const messages = new FileValidationLocalization();
57
52
  /**
58
- * Built-in rule factories. Each takes a constraint plus the consumer-supplied localized message
59
- * kit never ships English error strings. Returned rule is a callable function; the `Mime`
60
- * factory additionally attaches `.accept` so containing UI cmps (`FileUploader`,
61
- * `OpenFileButton`) can auto-derive the native picker filter / drag-over hint.
53
+ * Built-in rule factories. Each takes a constraint and an optional `message`; when omitted, the
54
+ * rule falls back to a localized default from `FileValidationLocalization` (kit ships no English
55
+ * literals — defaults follow `AppLocale`). The `Mime` factory additionally attaches `.accept` so
56
+ * containing UI cmps (`FileUploader`, `OpenFileButton`) can auto-derive the native picker filter /
57
+ * drag-over hint.
62
58
  *
63
59
  * @example
64
60
  * ```ts
65
61
  * const rules = [
66
- * FileRules.Mime('image/*', 'Please pick an image.'),
62
+ * FileRules.Mime('image/*'),
67
63
  * FileRules.MaxSize(5 * 1024 * 1024, 'Image must be under 5 MB.')
68
64
  * ];
69
65
  * const results = await FileValidator.validateMany(files, rules);
70
66
  * ```
71
67
  */
72
68
  export const FileRules = {
73
- MaxSize: (bytes, message) => (file) => file.size > bytes ? message : null,
74
- Mime: (accept, message) => {
75
- const rule = (file) => (matchesAccept(file, accept) ? null : message);
76
- rule.accept = accept;
77
- return rule;
69
+ AudioBitrate: (maxKbps, message) => async (file) => {
70
+ const duration = await readMediaDuration(file, 'audio');
71
+ if (!duration) {
72
+ return null;
73
+ }
74
+ const kbps = (file.size * 8) / duration / 1000;
75
+ return kbps > maxKbps ? (message ?? messages.maxBitrate(maxKbps)) : null;
76
+ },
77
+ AudioDuration: (maxSeconds, message) => async (file) => {
78
+ const duration = await readMediaDuration(file, 'audio');
79
+ return duration > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
80
+ },
81
+ AudioOnly: (message) => async (file) => {
82
+ const { width, height } = await readVideoDimensions(file);
83
+ return width > 0 || height > 0 ? (message ?? messages.audioOnly) : null;
84
+ },
85
+ Extensions: (extensions, message) => (file) => {
86
+ const extension = FileHelper.getFileExtension(file.name).toLowerCase();
87
+ const allowed = extensions.map((ext) => ext.replace(/^\./, '').toLowerCase());
88
+ return allowed.includes(extension) ? null : (message ?? messages.extensions(formatExtensions(extensions)));
78
89
  },
79
90
  ImageDimensions: (max, message) => async (file) => {
80
91
  const { width, height } = await readImageDimensions(file);
81
- return width > max.width || height > max.height ? message : null;
92
+ return width > max.width || height > max.height ? (message ?? messages.maxDimensions(max.width, max.height)) : null;
82
93
  },
83
94
  ImageMinDimensions: (min, message) => async (file) => {
84
95
  const { width, height } = await readImageDimensions(file);
85
- return width < min.width || height < min.height ? message : null;
96
+ return width < min.width || height < min.height ? (message ?? messages.minDimensions(min.width, min.height)) : null;
86
97
  },
87
- AudioDuration: (maxSeconds, message) => async (file) => {
88
- const duration = await readMediaDuration(file, 'audio');
89
- return duration > maxSeconds ? message : null;
98
+ MaxSize: (bytes, message) => (file) => file.size > bytes ? (message ?? messages.maxSize(StringHelper.toFileSizeString(bytes))) : null,
99
+ Mime: (accept, message) => {
100
+ const rule = (file) => (matchesAcceptedFileTypes(file, accept) ? null : (message ?? messages.unsupportedType));
101
+ rule.accept = accept;
102
+ return rule;
103
+ },
104
+ VideoAspectRatio: (ratios, message, tolerancePercent = 1) => async (file) => {
105
+ const { width, height } = await readVideoDimensions(file);
106
+ if (!width || !height) {
107
+ return message ?? messages.aspectRatio(ratios.join(', '));
108
+ }
109
+ const actual = width / height;
110
+ const matches = ratios.some((ratio) => {
111
+ const target = parseRatio(ratio);
112
+ return Number.isFinite(target) && Math.abs((actual / target) * 100 - 100) <= tolerancePercent;
113
+ });
114
+ return matches ? null : (message ?? messages.aspectRatio(ratios.join(', ')));
115
+ },
116
+ VideoDimensions: (max, message) => async (file) => {
117
+ const { width, height } = await readVideoDimensions(file);
118
+ return width > max.width || height > max.height ? (message ?? messages.maxDimensions(max.width, max.height)) : null;
90
119
  },
91
120
  VideoDuration: (maxSeconds, message) => async (file) => {
92
121
  const duration = await readMediaDuration(file, 'video');
93
- return duration > maxSeconds ? message : null;
122
+ return duration > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
94
123
  }
95
124
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Single async validation rule. Returns `null` (or empty array) when the file is valid,
3
- * a string (or array of strings) when invalid. The consumer supplies the localized message
4
- * when constructing the rule kit ships only rule factories, never English defaults.
3
+ * a string (or array of strings) when invalid. The message is supplied by the rule factory;
4
+ * when omitted it falls back to a localized default (`FileValidationLocalization`).
5
5
  *
6
6
  * Optional `accept` property: when set (only the `FileRules.Mime` factory does this), the
7
7
  * containing UI cmps (`FileUploader`, `OpenFileButton`) auto-derive the native picker filter
@@ -10,6 +10,13 @@
10
10
  export type FileValidationRule = ((file: File) => string | string[] | null | Promise<string | string[] | null>) & {
11
11
  accept?: string;
12
12
  };
13
+ /**
14
+ * One or more rule sets. A flat `FileValidationRule[]` is a single set applied to every file.
15
+ * A `FileValidationRule[][]` is several sets: per file, the first set whose `accept` matches
16
+ * (a set with no `accept` rule matches anything) is applied — lets one picker accept different
17
+ * file types with different rules. A file matching no set is rejected as an unsupported type.
18
+ */
19
+ export type FileValidationRuleSets = FileValidationRule[][];
13
20
  /** Aggregate validation result for a single file across one or more rules. */
14
21
  export type FileValidationResult = {
15
22
  file: File;
@@ -1,5 +1,5 @@
1
- import type { FileValidationResult, FileValidationRule } from './file-validation-types';
1
+ import type { FileValidationResult, FileValidationRule, FileValidationRuleSets } from './file-validation-types';
2
2
  export declare class FileValidator {
3
- static validate(file: File, rules: FileValidationRule[]): Promise<FileValidationResult>;
4
- static validateMany(files: File[], rules: FileValidationRule[]): Promise<FileValidationResult[]>;
3
+ static validate(file: File, rules: FileValidationRule[] | FileValidationRuleSets): Promise<FileValidationResult>;
4
+ static validateMany(files: File[], rules: FileValidationRule[] | FileValidationRuleSets): Promise<FileValidationResult[]>;
5
5
  }
@@ -1,9 +1,23 @@
1
+ import { FileValidationLocalization } from './file-validation-localization';
2
+ import { pickRuleSet, toRuleSets } from './file-validation-rule-sets';
1
3
  const toArray = (v) => (v === null ? [] : Array.isArray(v) ? v : [v]);
4
+ const messages = new FileValidationLocalization();
2
5
  export class FileValidator {
3
6
  static async validate(file, rules) {
7
+ const sets = toRuleSets(rules);
8
+ const set = sets.length > 1 ? pickRuleSet(file, sets) : (sets[0] ?? []);
9
+ if (set === null) {
10
+ return { file, isValid: false, errors: [messages.unsupportedType] };
11
+ }
4
12
  const errors = [];
5
- for (const rule of rules) {
6
- const result = await rule(file);
13
+ for (const rule of set) {
14
+ let result;
15
+ try {
16
+ result = await rule(file);
17
+ }
18
+ catch {
19
+ return { file, isValid: false, errors: [messages.invalidFile] };
20
+ }
7
21
  const msgs = toArray(result);
8
22
  if (msgs.length > 0) {
9
23
  errors.push(...msgs);
@@ -1,5 +1,5 @@
1
1
  export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
2
- export type { FileValidationResult, FileValidationRule } from './file-validation-types';
2
+ export type { FileValidationResult, FileValidationRule, FileValidationRuleSets } from './file-validation-types';
3
3
  export type { BlobKind, BlobKinds, BlobUpload, BlobUploadStrategy, UploadingFile, UploadingFileStatus } from './upload-types';
4
4
  export type { UploadMediaStoreOptions } from './upload-media-store.svelte';
5
5
  export { toBlobWithName, toFileWithUploadUrl } from './types';
@@ -8,6 +8,7 @@ export { openFile } from './open-file';
8
8
  export { FileHelper } from './file-helper';
9
9
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
10
10
  export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
11
+ export { deriveAccept } from './file-validation-rule-sets';
11
12
  export { resizeBlob, resizeImage } from './image-resizer';
12
13
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
13
14
  export { FilesProvider } from './files-provider';
@@ -5,6 +5,7 @@ export { openFile } from './open-file';
5
5
  export { FileHelper } from './file-helper';
6
6
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
7
7
  export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
8
+ export { deriveAccept } from './file-validation-rule-sets';
8
9
  export { resizeBlob, resizeImage } from './image-resizer';
9
10
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
10
11
  export { FilesProvider } from './files-provider';