From 2e11b965374b1c7c954597fcd78e2ad769be58c6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 2 Oct 2025 23:12:15 +0200 Subject: [PATCH] refactor: move `OC.MimeType` to `src` and add `vitest` unit tests Signed-off-by: Ferdinand Thiessen --- core/js/merged-template-prepend.json | 1 - core/js/mimetype.js | 106 --------------------- core/js/tests/specs/mimeTypeSpec.js | 135 --------------------------- core/src/OC/index.js | 2 + core/src/OC/mimeType.js | 94 +++++++++++++++++++ core/src/tests/OC/mimeType.spec.ts | 118 +++++++++++++++++++++++ 6 files changed, 214 insertions(+), 242 deletions(-) delete mode 100644 core/js/mimetype.js delete mode 100644 core/js/tests/specs/mimeTypeSpec.js create mode 100644 core/src/OC/mimeType.js create mode 100644 core/src/tests/OC/mimeType.spec.ts diff --git a/core/js/merged-template-prepend.json b/core/js/merged-template-prepend.json index 01ffe223d7a..c7abf7ad3d6 100644 --- a/core/js/merged-template-prepend.json +++ b/core/js/merged-template-prepend.json @@ -1,5 +1,4 @@ [ - "mimetype.js", "mimetypelist.js", "select2-toggleselect.js" ] diff --git a/core/js/mimetype.js b/core/js/mimetype.js deleted file mode 100644 index ae23ca62e2e..00000000000 --- a/core/js/mimetype.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2015 ownCloud, Inc. - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/** - * Namespace to hold functions related to convert mimetype to icons - * - * @namespace - */ -OC.MimeType = { - - /** - * Cache that maps mimeTypes to icon urls - */ - _mimeTypeIcons: {}, - - /** - * Return the file icon we want to use for the given mimeType. - * The file needs to be present in the supplied file list - * - * @param {string} mimeType The mimeType we want an icon for - * @param {Array} files The available icons in this theme - * @return {string} The icon to use or null if there is no match - */ - _getFile: function(mimeType, files) { - const icon = mimeType.replace(new RegExp('/', 'g'), '-') - - // Generate path - if (mimeType === 'dir' && files.includes('folder')) { - return 'folder' - } else if (mimeType === 'dir-encrypted' && files.includes('folder-encrypted')) { - return 'folder-encrypted' - } else if (mimeType === 'dir-shared' && files.includes('folder-shared')) { - return 'folder-shared' - } else if (mimeType === 'dir-public' && files.includes('folder-public')) { - return 'folder-public' - } else if ((mimeType === 'dir-external' || mimeType === 'dir-external-root') && files.includes('folder-external')) { - return 'folder-external' - } else if (files.includes(icon)) { - return icon - } else if (files.includes(icon.split('-')[0])) { - return icon.split('-')[0] - } else if (files.includes('file')) { - return 'file' - } - - return null - }, - - /** - * Return the url to icon of the given mimeType - * - * @param {string} mimeType The mimeType to get the icon for - * @return {string} Url to the icon for mimeType - */ - getIconUrl: function(mimeType) { - if (typeof mimeType === 'undefined') { - return undefined - } - - while (mimeType in OC.MimeTypeList.aliases) { - mimeType = OC.MimeTypeList.aliases[mimeType] - } - - if (mimeType in OC.MimeType._mimeTypeIcons) { - return OC.MimeType._mimeTypeIcons[mimeType] - } - - // First try to get the correct icon from the current theme - let gotIcon = null - let path = '' - if (OC.theme.folder !== '' && Array.isArray(OC.MimeTypeList.themes[OC.theme.folder])) { - path = OC.getRootPath() + '/themes/' + OC.theme.folder + '/core/img/filetypes/' - const icon = OC.MimeType._getFile(mimeType, OC.MimeTypeList.themes[OC.theme.folder]) - - if (icon !== null) { - gotIcon = true - path += icon - } - } - if (OCA.Theming && gotIcon === null) { - path = OC.generateUrl('/apps/theming/img/core/filetypes/') - path += OC.MimeType._getFile(mimeType, OC.MimeTypeList.files) - gotIcon = true - } - - // If we do not yet have an icon fall back to the default - if (gotIcon === null) { - path = OC.getRootPath() + '/core/img/filetypes/' - path += OC.MimeType._getFile(mimeType, OC.MimeTypeList.files) - } - - path += '.svg' - - if (OCA.Theming) { - path += '?v=' + OCA.Theming.cacheBuster - } - - // Cache the result - OC.MimeType._mimeTypeIcons[mimeType] = path - return path - }, - -} diff --git a/core/js/tests/specs/mimeTypeSpec.js b/core/js/tests/specs/mimeTypeSpec.js deleted file mode 100644 index 567612ddc83..00000000000 --- a/core/js/tests/specs/mimeTypeSpec.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2015 ownCloud Inc. - * SPDX-License-Identifier: AGPL-3.0-only - */ - -describe('MimeType tests', function() { - let _files - let _aliases - let _theme - - beforeEach(function() { - _files = OC.MimeTypeList.files - _aliases = OC.MimeTypeList.aliases - _theme = OC.MimeTypeList.themes.abc - - OC.MimeTypeList.files = ['folder', 'folder-shared', 'folder-external', 'foo-bar', 'foo', 'file'] - OC.MimeTypeList.aliases = { 'app/foobar': 'foo/bar' } - OC.MimeTypeList.themes.abc = ['folder'] - }) - - afterEach(function() { - OC.MimeTypeList.files = _files - OC.MimeTypeList.aliases = _aliases - OC.MimeTypeList.themes.abc = _theme - }) - - describe('_getFile', function() { - it('returns the correct icon for "dir"', function() { - const res = OC.MimeType._getFile('dir', OC.MimeTypeList.files) - expect(res).toEqual('folder') - }) - - it('returns the correct icon for "dir-shared"', function() { - const res = OC.MimeType._getFile('dir-shared', OC.MimeTypeList.files) - expect(res).toEqual('folder-shared') - }) - - it('returns the correct icon for "dir-external"', function() { - const res = OC.MimeType._getFile('dir-external', OC.MimeTypeList.files) - expect(res).toEqual('folder-external') - }) - - it('returns the correct icon for a mimetype for which we have an icon', function() { - const res = OC.MimeType._getFile('foo/bar', OC.MimeTypeList.files) - expect(res).toEqual('foo-bar') - }) - - it('returns the correct icon for a mimetype for which we only have a general mimetype icon', function() { - const res = OC.MimeType._getFile('foo/baz', OC.MimeTypeList.files) - expect(res).toEqual('foo') - }) - - it('return the file mimetype if we have no matching icon but do have a file icon', function() { - const res = OC.MimeType._getFile('foobar', OC.MimeTypeList.files) - expect(res).toEqual('file') - }) - - it('return null if we do not have a matching icon', function() { - const res = OC.MimeType._getFile('xyz', []) - expect(res).toEqual(null) - }) - }) - - describe('getIconUrl', function() { - describe('no theme', function() { - let _themeFolder - - beforeEach(function() { - _themeFolder = OC.theme.folder - OC.theme.folder = '' - // Clear mimetypeIcons caches - OC.MimeType._mimeTypeIcons = {} - }) - - afterEach(function() { - OC.theme.folder = _themeFolder - }) - - it('return undefined if the an icon for undefined is requested', function() { - const res = OC.MimeType.getIconUrl(undefined) - expect(res).toEqual(undefined) - }) - - it('return the url for the mimetype file', function() { - const res = OC.MimeType.getIconUrl('file') - expect(res).toEqual(OC.getRootPath() + '/core/img/filetypes/file.svg') - }) - - it('test if the cache works correctly', function() { - OC.MimeType._mimeTypeIcons = {} - expect(Object.keys(OC.MimeType._mimeTypeIcons).length).toEqual(0) - - let res = OC.MimeType.getIconUrl('dir') - expect(Object.keys(OC.MimeType._mimeTypeIcons).length).toEqual(1) - expect(OC.MimeType._mimeTypeIcons.dir).toEqual(res) - - res = OC.MimeType.getIconUrl('dir-shared') - expect(Object.keys(OC.MimeType._mimeTypeIcons).length).toEqual(2) - expect(OC.MimeType._mimeTypeIcons['dir-shared']).toEqual(res) - }) - - it('test if alaiases are converted correctly', function() { - const res = OC.MimeType.getIconUrl('app/foobar') - expect(res).toEqual(OC.getRootPath() + '/core/img/filetypes/foo-bar.svg') - expect(OC.MimeType._mimeTypeIcons['foo/bar']).toEqual(res) - }) - }) - - describe('themes', function() { - let _themeFolder - - beforeEach(function() { - _themeFolder = OC.theme.folder - OC.theme.folder = 'abc' - // Clear mimetypeIcons caches - OC.MimeType._mimeTypeIcons = {} - }) - - afterEach(function() { - OC.theme.folder = _themeFolder - }) - - it('test if theme path is used if a theme icon is availble', function() { - const res = OC.MimeType.getIconUrl('dir') - expect(res).toEqual(OC.getRootPath() + '/themes/abc/core/img/filetypes/folder.svg') - }) - - it('test if we fallback to the default theme if no icon is available in the theme', function() { - const res = OC.MimeType.getIconUrl('dir-shared') - expect(res).toEqual(OC.getRootPath() + '/core/img/filetypes/folder-shared.svg') - }) - }) - }) -}) diff --git a/core/src/OC/index.js b/core/src/OC/index.js index 908bb8e275d..f8a97119e3f 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -63,6 +63,7 @@ import { showMenu, unregisterMenu, } from './menu.js' +import * as MimeType from './mimeType.js' import msg from './msg.js' import { redirect, reload } from './navigation.js' import Notification from './notification.js' @@ -127,6 +128,7 @@ export default { currentUser, dialogs: Dialogs, EventSource, + MimeType, /** * Returns the currently logged in user or null if there is no logged in * user (public page mode) diff --git a/core/src/OC/mimeType.js b/core/src/OC/mimeType.js new file mode 100644 index 00000000000..9e51da631c7 --- /dev/null +++ b/core/src/OC/mimeType.js @@ -0,0 +1,94 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' + +const iconCache = new Map() + +/** + * Return the url to icon of the given mimeType + * + * @param {string} mimeType The mimeType to get the icon for + * @return {string} Url to the icon for mimeType + */ +export function getIconUrl(mimeType) { + if (typeof mimeType === 'undefined') { + return undefined + } + + while (mimeType in window.OC.MimeTypeList.aliases) { + mimeType = window.OC.MimeTypeList.aliases[mimeType] + } + + if (!iconCache.has(mimeType)) { + // First try to get the correct icon from the current theme + let gotIcon = null + let path = '' + if (OC.theme.folder !== '' && Array.isArray(OC.MimeTypeList.themes[OC.theme.folder])) { + path = generateUrl('/themes/' + window.OC.theme.folder + '/core/img/filetypes/') + const icon = getMimeTypeIcon(mimeType, window.OC.MimeTypeList.themes[OC.theme.folder]) + + if (icon !== null) { + gotIcon = true + path += icon + } + } + if (window.OCA.Theming && gotIcon === null) { + path = generateUrl('/apps/theming/img/core/filetypes/') + path += getMimeTypeIcon(mimeType, window.OC.MimeTypeList.files) + gotIcon = true + } + + // If we do not yet have an icon fall back to the default + if (gotIcon === null) { + path = generateUrl('/core/img/filetypes/') + path += getMimeTypeIcon(mimeType, window.OC.MimeTypeList.files) + } + + path += '.svg' + + if (window.OCA.Theming) { + path += '?v=' + window.OCA.Theming.cacheBuster + } + + // Cache the result + iconCache.set(mimeType, path) + } + + return iconCache.get(mimeType) +} + +/** + * Return the file icon we want to use for the given mimeType. + * The file needs to be present in the supplied file list + * + * @param {string} mimeType The mimeType we want an icon for + * @param {string[]} files The available icons in this theme + * @return {string | null} The icon to use or null if there is no match + */ +function getMimeTypeIcon(mimeType, files) { + const icon = mimeType.replace(new RegExp('/', 'g'), '-') + + // Generate path + if (mimeType === 'dir' && files.includes('folder')) { + return 'folder' + } else if (mimeType === 'dir-encrypted' && files.includes('folder-encrypted')) { + return 'folder-encrypted' + } else if (mimeType === 'dir-shared' && files.includes('folder-shared')) { + return 'folder-shared' + } else if (mimeType === 'dir-public' && files.includes('folder-public')) { + return 'folder-public' + } else if ((mimeType === 'dir-external' || mimeType === 'dir-external-root') && files.includes('folder-external')) { + return 'folder-external' + } else if (files.includes(icon)) { + return icon + } else if (files.includes(icon.split('-')[0])) { + return icon.split('-')[0] + } else if (files.includes('file')) { + return 'file' + } + + return null +} diff --git a/core/src/tests/OC/mimeType.spec.ts b/core/src/tests/OC/mimeType.spec.ts new file mode 100644 index 00000000000..1c10e40aec7 --- /dev/null +++ b/core/src/tests/OC/mimeType.spec.ts @@ -0,0 +1,118 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const generateUrl = vi.hoisted(() => vi.fn((url) => join('/ROOT', url))) + +vi.mock('@nextcloud/router', () => ({ + generateUrl, +})) + +beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() +}) + +describe('OC.MimeType tests', async () => { + beforeEach(async () => { + window.OC.MimeTypeList = { + aliases: { 'app/foobar': 'foo/bar' }, + files: ['folder', 'folder-shared', 'folder-external', 'foo-bar', 'foo', 'file'], + themes: { + abc: ['folder'], + }, + } + }) + + describe('no theme', async () => { + beforeEach(async () => { + window.OC.theme ??= {} + window.OC.theme.folder = '' + }) + + it.for([ + // returns the correct icon for a mimetype + { mimeType: 'file', icon: 'file' }, + { mimeType: 'dir', icon: 'folder' }, + { mimeType: 'dir-shared', icon: 'folder-shared' }, + { mimeType: 'dir-external', icon: 'folder-external' }, + // returns the correct icon for a mimetype for which we have an icon + { mimeType: 'foo/bar', icon: 'foo-bar' }, + // returns the correct icon for a mimetype for which we only have a general mimetype icon + { mimeType: 'foo/baz', icon: 'foo' }, + // return the file mimetype if we have no matching icon but do have a file icon + { mimeType: 'foobar', icon: 'file' }, + ])('returns correct icon', async ({ icon, mimeType }) => { + const { getIconUrl } = await getMethod() + expect(getIconUrl(mimeType)).toEqual(`/ROOT/core/img/filetypes/${icon}.svg`) + }) + + it('returns undefined if the an icon for undefined is requested', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl(undefined)).toEqual(undefined) + }) + + it('uses the cache if available', async () => { + const { getIconUrl } = await getMethod() + expect(generateUrl).not.toHaveBeenCalled() + + expect(getIconUrl('dir')).toEqual('/ROOT/core/img/filetypes/folder.svg') + expect(generateUrl).toHaveBeenCalledTimes(1) + + expect(getIconUrl('dir')).toEqual('/ROOT/core/img/filetypes/folder.svg') + expect(generateUrl).toHaveBeenCalledTimes(1) + + expect(getIconUrl('dir-shared')).toEqual('/ROOT/core/img/filetypes/folder-shared.svg') + expect(generateUrl).toHaveBeenCalledTimes(2) + }) + + it('converts aliases correctly', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl('app/foobar')).toEqual('/ROOT/core/img/filetypes/foo-bar.svg') + }) + }) + + describe('with legacy themes', async () => { + beforeEach(async () => { + window.OC.theme ??= {} + window.OC.theme.folder = 'abc' + }) + + it('uses theme path if a theme icon is availble', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl('dir')).toEqual('/ROOT/themes/abc/core/img/filetypes/folder.svg') + }) + + it('fallbacks to the default theme if no icon is available in the theme', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl('dir-shared')).toEqual('/ROOT/core/img/filetypes/folder-shared.svg') + }) + }) + + describe('with theming app', async () => { + beforeEach(async () => { + window.OC.theme ??= {} + window.OC.theme.folder = '' + window.OCA.Theming ??= {} + window.OCA.Theming.cacheBuster = '1cacheBuster2' + }) + + it('uses the correct theming URL', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl('dir')).toMatch('/apps/theming/img/core/filetypes/folder.svg') + }) + + it('uses the cache buster', async () => { + const { getIconUrl } = await getMethod() + expect(getIconUrl('file')).toMatch(/\?v=1cacheBuster2$/) + }) + }) +}) + +async function getMethod() { + return await import('../../OC/mimeType.js') +}