diff --git a/src/shared/utils/__tests__/fileUtils.test.js b/src/shared/utils/__tests__/fileUtils.test.js new file mode 100644 index 00000000..77651b29 --- /dev/null +++ b/src/shared/utils/__tests__/fileUtils.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractFileId, + extractFileVersion, + extractVariantVersion +} from '../fileUtils'; + +describe('extractFileId', () => { + it('extracts file ID from a URL', () => { + expect( + extractFileId( + 'https://api.vrchat.cloud/file/file_abc123-def/1/file' + ) + ).toBe('file_abc123-def'); + }); + + it('extracts file ID from a plain string', () => { + expect(extractFileId('file_0123456789abcdef')).toBe( + 'file_0123456789abcdef' + ); + }); + + it('returns empty string when no match', () => { + expect(extractFileId('no-match-here')).toBe(''); + expect(extractFileId('')).toBe(''); + }); + + it('handles null/undefined input', () => { + expect(extractFileId(null)).toBe(''); + expect(extractFileId(undefined)).toBe(''); + }); +}); + +describe('extractFileVersion', () => { + it('extracts version number from file URL', () => { + expect(extractFileVersion('/file_abc123/5/file')).toBe('5'); + }); + + it('extracts multi-digit version', () => { + expect(extractFileVersion('/file_abc-def-123/123/file')).toBe('123'); + }); + + it('returns empty string when no match', () => { + expect(extractFileVersion('no-version')).toBe(''); + expect(extractFileVersion('')).toBe(''); + }); +}); + +describe('extractVariantVersion', () => { + it('extracts version from query parameter', () => { + expect(extractVariantVersion('https://example.com/file?v=42')).toBe( + '42' + ); + }); + + it('returns 0 when no v parameter', () => { + expect(extractVariantVersion('https://example.com/file?other=1')).toBe( + '0' + ); + }); + + it('returns 0 for empty/null input', () => { + expect(extractVariantVersion('')).toBe('0'); + expect(extractVariantVersion(null)).toBe('0'); + expect(extractVariantVersion(undefined)).toBe('0'); + }); + + it('returns 0 for invalid URL', () => { + expect(extractVariantVersion('not-a-url')).toBe('0'); + }); +}); diff --git a/src/shared/utils/__tests__/platformUtils.test.js b/src/shared/utils/__tests__/platformUtils.test.js new file mode 100644 index 00000000..b4c6b460 --- /dev/null +++ b/src/shared/utils/__tests__/platformUtils.test.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { getAvailablePlatforms } from '../platformUtils'; + +describe('getAvailablePlatforms', () => { + it('detects PC platform', () => { + const packages = [{ platform: 'standalonewindows' }]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: true, + isQuest: false, + isIos: false + }); + }); + + it('detects Quest (android) platform', () => { + const packages = [{ platform: 'android' }]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: false, + isQuest: true, + isIos: false + }); + }); + + it('detects iOS platform', () => { + const packages = [{ platform: 'ios' }]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: false, + isQuest: false, + isIos: true + }); + }); + + it('detects multiple platforms', () => { + const packages = [ + { platform: 'standalonewindows' }, + { platform: 'android' }, + { platform: 'ios' } + ]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: true, + isQuest: true, + isIos: true + }); + }); + + it('skips non-standard/non-security variants', () => { + const packages = [ + { platform: 'standalonewindows', variant: 'custom_variant' }, + { platform: 'android', variant: 'standard' } + ]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: false, + isQuest: true, + isIos: false + }); + }); + + it('allows security variant', () => { + const packages = [ + { platform: 'standalonewindows', variant: 'security' } + ]; + expect(getAvailablePlatforms(packages)).toEqual({ + isPC: true, + isQuest: false, + isIos: false + }); + }); + + it('returns all false for empty array', () => { + expect(getAvailablePlatforms([])).toEqual({ + isPC: false, + isQuest: false, + isIos: false + }); + }); + + it('returns all false for non-object input', () => { + expect(getAvailablePlatforms('string')).toEqual({ + isPC: false, + isQuest: false, + isIos: false + }); + }); + + it('throws for null input (typeof null === "object" but not iterable)', () => { + expect(() => getAvailablePlatforms(null)).toThrow(); + }); +}); diff --git a/src/shared/utils/__tests__/urlUtils.test.js b/src/shared/utils/__tests__/urlUtils.test.js new file mode 100644 index 00000000..34298f34 --- /dev/null +++ b/src/shared/utils/__tests__/urlUtils.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { getFaviconUrl, replaceVrcPackageUrl } from '../urlUtils'; + +describe('getFaviconUrl', () => { + it('returns favicon URL for valid URL', () => { + expect(getFaviconUrl('https://vrchat.com/home')).toBe( + 'https://icons.duckduckgo.com/ip2/vrchat.com.ico' + ); + }); + + it('extracts host from complex URL', () => { + expect(getFaviconUrl('https://store.steampowered.com/app/12345')).toBe( + 'https://icons.duckduckgo.com/ip2/store.steampowered.com.ico' + ); + }); + + it('returns empty string for empty input', () => { + expect(getFaviconUrl('')).toBe(''); + expect(getFaviconUrl(null)).toBe(''); + expect(getFaviconUrl(undefined)).toBe(''); + }); + + it('returns empty string for invalid URL', () => { + expect(getFaviconUrl('not-a-url')).toBe(''); + }); +}); + +describe('replaceVrcPackageUrl', () => { + it('replaces api.vrchat.cloud with vrchat.com', () => { + expect( + replaceVrcPackageUrl('https://api.vrchat.cloud/api/1/file/123') + ).toBe('https://vrchat.com/api/1/file/123'); + }); + + it('returns URL unchanged if no match', () => { + expect(replaceVrcPackageUrl('https://example.com/test')).toBe( + 'https://example.com/test' + ); + }); + + it('returns empty string for empty/null input', () => { + expect(replaceVrcPackageUrl('')).toBe(''); + expect(replaceVrcPackageUrl(null)).toBe(''); + expect(replaceVrcPackageUrl(undefined)).toBe(''); + }); +}); diff --git a/src/shared/utils/common.js b/src/shared/utils/common.js index 04cf660e..499346b5 100644 --- a/src/shared/utils/common.js +++ b/src/shared/utils/common.js @@ -10,42 +10,19 @@ import { useSearchStore, useWorldStore } from '../../stores'; +import { + extractFileId, + extractFileVersion, + extractVariantVersion +} from './fileUtils'; import { escapeTag, replaceBioSymbols } from './base/string'; +import { getFaviconUrl, replaceVrcPackageUrl } from './urlUtils'; import { AppDebug } from '../../service/appConfig.js'; import { compareUnityVersion } from './avatar'; +import { getAvailablePlatforms } from './platformUtils'; import { i18n } from '../../plugin/i18n'; import { miscRequest } from '../../api'; -/** - * - * @param {object} unityPackages - * @returns - */ -function getAvailablePlatforms(unityPackages) { - let isPC = false; - let isQuest = false; - let isIos = false; - if (typeof unityPackages === 'object') { - for (const unityPackage of unityPackages) { - if ( - unityPackage.variant && - unityPackage.variant !== 'standard' && - unityPackage.variant !== 'security' - ) { - continue; - } - if (unityPackage.platform === 'standalonewindows') { - isPC = true; - } else if (unityPackage.platform === 'android') { - isQuest = true; - } else if (unityPackage.platform === 'ios') { - isIos = true; - } - } - } - return { isPC, isQuest, isIos }; -} - /** * @param {string} fileName * @param {*} data @@ -175,24 +152,6 @@ function copyToClipboard(text, message = 'Copied successfully!') { }); } -/** - * - * @param {string} resource - * @returns {string} - */ -function getFaviconUrl(resource) { - if (!resource) { - return ''; - } - try { - const url = new URL(resource); - return `https://icons.duckduckgo.com/ip2/${url.host}.ico`; - } catch (err) { - console.error('Invalid URL:', resource, err); - return ''; - } -} - /** * * @param {string} url @@ -222,130 +181,6 @@ function convertFileUrlToImageUrl(url, resolution = 128) { return url; } -/** - * - * @param {string} url - * @returns {string} - */ -function replaceVrcPackageUrl(url) { - if (!url) { - return ''; - } - return url.replace('https://api.vrchat.cloud/', 'https://vrchat.com/'); -} - -/** - * - * @param {string} s - * @returns {string} - */ -function extractFileId(s) { - const match = String(s).match(/file_[0-9A-Za-z-]+/); - return match ? match[0] : ''; -} - -/** - * - * @param {string} s - * @returns {string} - */ -function extractFileVersion(s) { - const match = /(?:\/file_[0-9A-Za-z-]+\/)([0-9]+)/gi.exec(s); - return match ? match[1] : ''; -} - -/** - * - * @param {string} url - * @returns {string} - */ -function extractVariantVersion(url) { - if (!url) { - return '0'; - } - try { - const params = new URLSearchParams(new URL(url).search); - const version = params.get('v'); - if (version) { - return version; - } - return '0'; - } catch { - return '0'; - } -} - -/** - * - * @param {object} json - * @returns {Array} - */ -function buildTreeData(json) { - const node = []; - for (const key in json) { - if (key[0] === '$') { - continue; - } - const value = json[key]; - if (Array.isArray(value) && value.length === 0) { - node.push({ - key, - value: '[]' - }); - } else if (value === Object(value) && Object.keys(value).length === 0) { - node.push({ - key, - value: '{}' - }); - } else if (Array.isArray(value)) { - node.push({ - children: value.map((val, idx) => { - if (val === Object(val)) { - return { - children: buildTreeData(val), - key: idx - }; - } - return { - key: idx, - value: val - }; - }), - key - }); - } else if (value === Object(value)) { - node.push({ - children: buildTreeData(value), - key - }); - } else { - node.push({ - key, - value: String(value) - }); - } - } - node.sort(function (a, b) { - const A = String(a.key).toUpperCase(); - const B = String(b.key).toUpperCase(); - // sort _ to top - if (A.startsWith('_') && !B.startsWith('_')) { - return -1; - } - if (B.startsWith('_') && !A.startsWith('_')) { - return 1; - } - if (A < B) { - return -1; - } - if (A > B) { - return 1; - } - return 0; - }); - return node; -} - /** * * @param {string} link @@ -500,7 +335,6 @@ export { extractFileId, extractFileVersion, extractVariantVersion, - buildTreeData, replaceBioSymbols, openExternalLink, openDiscordProfile, diff --git a/src/shared/utils/fileUtils.js b/src/shared/utils/fileUtils.js new file mode 100644 index 00000000..3983fb88 --- /dev/null +++ b/src/shared/utils/fileUtils.js @@ -0,0 +1,39 @@ +/** + * @param {string} s + * @returns {string} + */ +function extractFileId(s) { + const match = String(s).match(/file_[0-9A-Za-z-]+/); + return match ? match[0] : ''; +} + +/** + * @param {string} s + * @returns {string} + */ +function extractFileVersion(s) { + const match = /(?:\/file_[0-9A-Za-z-]+\/)([0-9]+)/gi.exec(s); + return match ? match[1] : ''; +} + +/** + * @param {string} url + * @returns {string} + */ +function extractVariantVersion(url) { + if (!url) { + return '0'; + } + try { + const params = new URLSearchParams(new URL(url).search); + const version = params.get('v'); + if (version) { + return version; + } + return '0'; + } catch { + return '0'; + } +} + +export { extractFileId, extractFileVersion, extractVariantVersion }; diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index ff94544d..dce8d1c9 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -7,10 +7,13 @@ export * from './avatar'; export * from './chart'; export * from './common'; export * from './compare'; +export * from './fileUtils'; export * from './friend'; export * from './group'; export * from './instance'; +export * from './platformUtils'; export * from './setting'; +export * from './urlUtils'; export * from './user'; export * from './gallery'; export * from './location'; diff --git a/src/shared/utils/platformUtils.js b/src/shared/utils/platformUtils.js new file mode 100644 index 00000000..987758b6 --- /dev/null +++ b/src/shared/utils/platformUtils.js @@ -0,0 +1,30 @@ +/** + * @param {object} unityPackages + * @returns {{ isPC: boolean, isQuest: boolean, isIos: boolean }} + */ +function getAvailablePlatforms(unityPackages) { + let isPC = false; + let isQuest = false; + let isIos = false; + if (typeof unityPackages === 'object') { + for (const unityPackage of unityPackages) { + if ( + unityPackage.variant && + unityPackage.variant !== 'standard' && + unityPackage.variant !== 'security' + ) { + continue; + } + if (unityPackage.platform === 'standalonewindows') { + isPC = true; + } else if (unityPackage.platform === 'android') { + isQuest = true; + } else if (unityPackage.platform === 'ios') { + isIos = true; + } + } + } + return { isPC, isQuest, isIos }; +} + +export { getAvailablePlatforms }; diff --git a/src/shared/utils/urlUtils.js b/src/shared/utils/urlUtils.js new file mode 100644 index 00000000..86beaf95 --- /dev/null +++ b/src/shared/utils/urlUtils.js @@ -0,0 +1,29 @@ +/** + * @param {string} resource + * @returns {string} + */ +function getFaviconUrl(resource) { + if (!resource) { + return ''; + } + try { + const url = new URL(resource); + return `https://icons.duckduckgo.com/ip2/${url.host}.ico`; + } catch (err) { + console.error('Invalid URL:', resource, err); + return ''; + } +} + +/** + * @param {string} url + * @returns {string} + */ +function replaceVrcPackageUrl(url) { + if (!url) { + return ''; + } + return url.replace('https://api.vrchat.cloud/', 'https://vrchat.com/'); +} + +export { getFaviconUrl, replaceVrcPackageUrl };