diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..6b49d08 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + extends: [ + '@nextcloud/eslint-config', + ], + globals: { + OCA: 'readonly', + OC: 'readonly', + }, + overrides: [ + { + files: ['src/__tests__/**/*'], + rules: { + 'n/no-unpublished-import': 'off', + 'jsdoc/require-jsdoc': 'off', + }, + }, + ], +} diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml new file mode 100644 index 0000000..af11871 --- /dev/null +++ b/.github/workflows/lint-eslint.yml @@ -0,0 +1,100 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Lint eslint + +on: pull_request + +permissions: + contents: read + +concurrency: + group: lint-eslint-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest-low + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src}} + + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - '.eslintrc.*' + - '.eslintignore' + - '**.js' + - '**.ts' + - '**.vue' + + lint: + runs-on: ubuntu-latest + + needs: changes + if: needs.changes.outputs.src != 'false' + + name: NPM lint + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install dependencies + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: npm ci + + - name: Lint + run: npm run lint + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [changes, lint] + + if: always() + + # This is the summary, we just avoid to rename it so that branch protection rules still match + name: eslint + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml new file mode 100644 index 0000000..336cdd7 --- /dev/null +++ b/.github/workflows/node-test.yml @@ -0,0 +1,104 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Node tests + +on: + pull_request: + push: + branches: + - main + - master + - stable* + +permissions: + contents: read + +concurrency: + group: node-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest-low + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src}} + + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'vitest.config.*' + - '**.js' + - '**.ts' + - '**.vue' + + test: + runs-on: ubuntu-latest + + needs: changes + if: needs.changes.outputs.src != 'false' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install dependencies & build + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: | + npm ci + npm run build --if-present + + - name: Test + run: npm run test + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [changes, test] + + if: always() + + name: npm-test-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.test.result != 'success' }}; then exit 1; fi diff --git a/package.json b/package.json index 9a1a38c..c6bb488 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,13 @@ "build": "vite --mode production build", "dev": "vite --mode development build", "watch": "vite --mode development build --watch", + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.vue,.ts src", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "@mdi/svg": "^7.3.67", + "@nextcloud/auth": "^2.5.0", "@nextcloud/axios": "^2.4.0", "@nextcloud/dialogs": "^5.0.0", "@nextcloud/event-bus": "^3.1.0", @@ -37,6 +39,8 @@ "@nextcloud/l10n": "^3.0.0", "@nextcloud/logger": "^3.0.0", "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", "jszip": "^3.10.1" }, "devDependencies": { diff --git a/src/__tests__/MindMap.spec.js b/src/__tests__/MindMap.spec.js new file mode 100644 index 0000000..0cae0c0 --- /dev/null +++ b/src/__tests__/MindMap.spec.js @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { shallowMount } from '@vue/test-utils' +import MindMap from '../views/MindMap.vue' + +vi.mock('@nextcloud/l10n', () => ({ getLanguage: () => 'en' })) +vi.mock('@nextcloud/router', () => ({ + generateUrl: (path, params = {}) => + Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, v ?? ''), + path, + ), +})) +vi.mock('@nextcloud/sharing/public', () => ({ + isPublicShare: vi.fn(() => false), + getSharingToken: () => '', +})) + +// Viewer mixin that supplies the props/methods the component expects +const viewerMixin = { + data() { + return { + fileList: [], + fileid: null, + source: null, + davPath: null, + } + }, + methods: { + doneLoading() {}, + handleWebviewerloaded() {}, + }, +} + +function mountMindMap(dataOverrides = {}) { + return shallowMount(MindMap, { + mixins: [ + { + ...viewerMixin, + data() { + return { ...viewerMixin.data(), ...dataOverrides } + }, + }, + ], + }) +} + +describe('MindMap.vue', () => { + beforeEach(() => { + window.OCA = { FilesMindMap: { setFile: vi.fn() } } + }) + + afterEach(() => { + delete window.OCA + }) + + it('renders an iframe element', () => { + const wrapper = mountMindMap() + expect(wrapper.find('iframe').exists()).toBe(true) + }) + + describe('iframeSrc computed property', () => { + it('uses source when available', () => { + const wrapper = mountMindMap({ + source: '/remote.php/dav/files/user/test.km', + }) + expect(wrapper.vm.iframeSrc).toContain('/remote.php/dav/files/user/test.km') + }) + + it('falls back to davPath when source is null', () => { + const wrapper = mountMindMap({ + source: null, + davPath: '/dav/path/test.km', + }) + expect(wrapper.vm.iframeSrc).toContain('/dav/path/test.km') + }) + }) + + describe('file computed property', () => { + it('returns the file whose fileid matches the current fileid', () => { + const file = { fileid: 7, name: 'test.km' } + const wrapper = mountMindMap({ fileList: [file], fileid: 7 }) + expect(wrapper.vm.file).toBe(file) + }) + + it('returns undefined when no file matches', () => { + const wrapper = mountMindMap({ + fileList: [{ fileid: 1, name: 'other.km' }], + fileid: 99, + }) + expect(wrapper.vm.file).toBeUndefined() + }) + }) + + describe('lifecycle hooks', () => { + it('calls OCA.FilesMindMap.setFile on mount', () => { + mountMindMap({ + fileList: [{ fileid: 3, name: 'test.km' }], + fileid: 3, + }) + expect(window.OCA.FilesMindMap.setFile).toHaveBeenCalled() + }) + + it('removes the webviewerloaded event listener on destroy', () => { + const spy = vi.spyOn(document, 'removeEventListener') + const wrapper = mountMindMap() + wrapper.destroy() + expect(spy).toHaveBeenCalledWith('webviewerloaded', expect.anything()) + spy.mockRestore() + }) + }) +}) diff --git a/src/__tests__/mindmap.spec.js b/src/__tests__/mindmap.spec.js new file mode 100644 index 0000000..af88b3e --- /dev/null +++ b/src/__tests__/mindmap.spec.js @@ -0,0 +1,448 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import FilesMindMap from '../mindmap.js' +import { showMessage as showToast } from '@nextcloud/dialogs' +import axios from '@nextcloud/axios' + +const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) + +vi.mock('@nextcloud/l10n', () => ({ + translate: (_app, text) => text, +})) + +vi.mock('@nextcloud/router', () => ({ + generateUrl: (path) => `/nc${path}`, +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showMessage: vi.fn(() => ({ hideToast: vi.fn() })), +})) + +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: vi.fn(() => ({ uid: 'testuser' })), +})) + +vi.mock('@nextcloud/sharing/public', () => ({ + isPublicShare: vi.fn(() => false), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + emit: vi.fn(), +})) + +vi.mock('@mdi/svg/svg/pencil.svg?raw', () => ({ default: '' })) + +vi.mock('@nextcloud/files-legacy', () => ({ + FileAction: vi.fn().mockImplementation(opts => opts), + registerFileAction: vi.fn(), + addNewFileMenuEntry: vi.fn(), +})) + +vi.mock('@nextcloud/files', () => ({ + DefaultType: { HIDDEN: 'hidden' }, + FileAction: vi.fn().mockImplementation(opts => opts), + addNewFileMenuEntry: vi.fn(), + registerFileAction: vi.fn(), + File: vi.fn(), + Permission: { READ: 1, CREATE: 4, UPDATE: 2, DELETE: 8, SHARE: 16, ALL: 31 }, + getUniqueName: vi.fn(name => name), +})) + +vi.mock('@nextcloud/axios', () => { + const fn = vi.fn() + fn.get = vi.fn() + return { default: fn } +}) + +vi.mock('../plugins/km', () => ({ + default: { name: 'km', mimes: ['application/km'], encode: vi.fn(d => Promise.resolve(d)), decode: vi.fn() }, +})) +vi.mock('../plugins/freemind', () => ({ + default: { name: 'freemind', mimes: ['application/x-freemind'], encode: null, decode: vi.fn() }, +})) +vi.mock('../plugins/xmind', () => ({ + default: { name: 'xmind', mimes: ['application/vnd.xmind.workbook'], encode: null, decode: vi.fn() }, +})) + +describe('FilesMindMap', () => { + beforeEach(() => { + FilesMindMap._extensions = [] + FilesMindMap._file = {} + FilesMindMap._currentContext = null + vi.clearAllMocks() + }) + + // ─── Extension management ─────────────────────────────────────────────────── + + describe('registerExtension', () => { + it('registers a single extension object', () => { + const ext = { name: 'test', mimes: ['application/test'] } + FilesMindMap.registerExtension(ext) + expect(FilesMindMap._extensions).toHaveLength(1) + expect(FilesMindMap._extensions[0]).toBe(ext) + }) + + it('registers an array of extensions', () => { + const ext1 = { name: 'a', mimes: ['application/a'] } + const ext2 = { name: 'b', mimes: ['application/b'] } + FilesMindMap.registerExtension([ext1, ext2]) + expect(FilesMindMap._extensions).toHaveLength(2) + }) + }) + + describe('getExtensionByMime', () => { + it('returns the matching extension for a registered mime type', () => { + const ext = { name: 'km', mimes: ['application/km'] } + FilesMindMap.registerExtension(ext) + expect(FilesMindMap.getExtensionByMime('application/km')).toBe(ext) + }) + + it('returns null for an unknown mime type', () => { + expect(FilesMindMap.getExtensionByMime('application/unknown')).toBeNull() + }) + + it('returns null when no extensions are registered', () => { + expect(FilesMindMap.getExtensionByMime('application/km')).toBeNull() + }) + }) + + describe('isSupportedMime', () => { + it('returns true for a registered mime type', () => { + FilesMindMap.registerExtension({ name: 'km', mimes: ['application/km'] }) + expect(FilesMindMap.isSupportedMime('application/km')).toBe(true) + }) + + it('returns false for an unregistered mime type', () => { + expect(FilesMindMap.isSupportedMime('application/pdf')).toBe(false) + }) + }) + + describe('getSupportedMimetypes', () => { + it('returns a flat list of all mimes from all registered extensions', () => { + FilesMindMap.registerExtension([ + { name: 'a', mimes: ['application/a', 'application/a2'] }, + { name: 'b', mimes: ['application/b'] }, + ]) + expect(FilesMindMap.getSupportedMimetypes()).toEqual([ + 'application/a', + 'application/a2', + 'application/b', + ]) + }) + + it('returns an empty array when no extensions are registered', () => { + expect(FilesMindMap.getSupportedMimetypes()).toEqual([]) + }) + }) + + // ─── Notifications ───────────────────────────────────────────────────────── + + describe('showMessage', () => { + it('calls showToast with the message and the default 3 s timeout', () => { + FilesMindMap.showMessage('Hello') + expect(showToast).toHaveBeenCalledWith('Hello', { timeout: 3000 }) + }) + + it('calls showToast with a custom timeout', () => { + FilesMindMap.showMessage('Hello', 5000) + expect(showToast).toHaveBeenCalledWith('Hello', { timeout: 5000 }) + }) + + it('returns the toast object from showToast', () => { + const mockToast = { hideToast: vi.fn() } + showToast.mockReturnValue(mockToast) + expect(FilesMindMap.showMessage('Hello')).toBe(mockToast) + }) + }) + + describe('hideMessage', () => { + it('calls hideToast on the toast object', () => { + const mockToast = { hideToast: vi.fn() } + FilesMindMap.hideMessage(mockToast) + expect(mockToast.hideToast).toHaveBeenCalledOnce() + }) + + it('does not throw when called with null', () => { + expect(() => FilesMindMap.hideMessage(null)).not.toThrow() + }) + + it('does not throw when called with undefined', () => { + expect(() => FilesMindMap.hideMessage(undefined)).not.toThrow() + }) + + it('does not throw when the toast has no hideToast method', () => { + expect(() => FilesMindMap.hideMessage({})).not.toThrow() + }) + }) + + // ─── File state ──────────────────────────────────────────────────────────── + + describe('setFile', () => { + it('sets name, dir, and fullName from file object', () => { + FilesMindMap.setFile({ filename: '/documents/test.km', basename: 'test.km' }) + expect(FilesMindMap._file.name).toBe('test.km') + expect(FilesMindMap._file.dir).toBe('/documents') + expect(FilesMindMap._file.fullName).toBe('/documents/test.km') + }) + + it('sets dir to "/" for top-level files', () => { + FilesMindMap.setFile({ filename: '/test.km', basename: 'test.km' }) + expect(FilesMindMap._file.dir).toBe('/') + }) + + it('sets _currentContext from the resolved dir', () => { + FilesMindMap.setFile({ filename: '/docs/sub/test.km', basename: 'test.km' }) + expect(FilesMindMap._currentContext).toEqual(expect.objectContaining({ dir: '/docs/sub' })) + }) + }) + + // ─── Public share detection ──────────────────────────────────────────────── + + describe('isMindmapPublic', () => { + it('returns false when not on a public share page', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(false) + expect(FilesMindMap.isMindmapPublic()).toBe(false) + }) + + it('returns true when on a public share page with a supported mime type', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(true) + FilesMindMap.registerExtension({ name: 'km', mimes: ['application/km'] }) + + const input = document.createElement('input') + input.id = 'mimetype' + input.value = 'application/km' + document.body.appendChild(input) + try { + expect(FilesMindMap.isMindmapPublic()).toBe(true) + } finally { + document.body.removeChild(input) + } + }) + + it('returns false when on a public share page but mime type is unsupported', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(true) + + const input = document.createElement('input') + input.id = 'mimetype' + input.value = 'application/pdf' + document.body.appendChild(input) + try { + expect(FilesMindMap.isMindmapPublic()).toBe(false) + } finally { + document.body.removeChild(input) + } + }) + }) + + // ─── save() ──────────────────────────────────────────────────────────────── + + describe('save', () => { + it('calls fail immediately when the extension does not support encoding', () => { + FilesMindMap.registerExtension({ + name: 'freemind', + mimes: ['application/x-freemind'], + encode: null, + decode: null, + }) + FilesMindMap._file = { dir: '/docs', name: 'test.mm', mime: 'application/x-freemind' } + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + expect(fail).toHaveBeenCalledWith(expect.stringContaining('Does not support saving')) + }) + + it('sends a PUT request to the savefile URL on the happy path', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('encoded-content'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockResolvedValue({ data: { mtime: 200 } }) + + const success = vi.fn() + FilesMindMap.save('input-data', success, vi.fn()) + await flushPromises() + + expect(axios).toHaveBeenCalledWith(expect.objectContaining({ + method: 'PUT', + url: '/nc/apps/files_mindmap/ajax/savefile', + })) + expect(success).toHaveBeenCalledWith('File Saved') + }) + + it('updates _file.mtime from the server response', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockResolvedValue({ data: { mtime: 999 } }) + + FilesMindMap.save('data', vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.mtime).toBe(999) + }) + + it('calls fail with the server error message on failure', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockRejectedValue({ response: { data: { message: 'Quota exceeded' } } }) + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + await flushPromises() + + expect(fail).toHaveBeenCalledWith('Quota exceeded') + }) + + it('calls fail with generic message when error response has no message', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockRejectedValue(new Error('Network error')) + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + await flushPromises() + + expect(fail).toHaveBeenCalledWith('Save failed') + }) + }) + + // ─── load() ──────────────────────────────────────────────────────────────── + + describe('load', () => { + it('calls success with decoded file contents', async () => { + const decoded = { root: { data: { text: 'Test' } } } + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn(), + decode: vi.fn().mockResolvedValue(decoded), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('raw-content'), + mime: 'application/km', + writeable: true, + mtime: 9999, + }, + }) + + const success = vi.fn() + FilesMindMap.load(success, vi.fn()) + await flushPromises() + + expect(success).toHaveBeenCalledWith(JSON.stringify(decoded)) + }) + + it('sets _file.mime and _file.mtime from the server response', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn(), + decode: vi.fn().mockResolvedValue({}), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/km', + writeable: true, + mtime: 42, + }, + }) + + FilesMindMap.load(vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.mime).toBe('application/km') + expect(FilesMindMap._file.mtime).toBe(42) + }) + + it('calls failure when the HTTP request fails', async () => { + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + axios.get.mockRejectedValue({ + response: { data: { message: 'File not found' } }, + message: 'Request failed', + }) + + const failure = vi.fn() + FilesMindMap.load(vi.fn(), failure) + await flushPromises() + + expect(failure).toHaveBeenCalledWith('File not found') + }) + + it('calls failure for an unsupported mime type', async () => { + FilesMindMap._extensions = [] + FilesMindMap._file = { dir: '/docs', name: 'test.pdf' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/pdf', + writeable: true, + mtime: 100, + }, + }) + + const failure = vi.fn() + FilesMindMap.load(vi.fn(), failure) + await flushPromises() + + expect(failure).toHaveBeenCalledWith(expect.stringContaining('Unsupported file type')) + }) + + it('marks supportedWrite as false for read-only extensions', async () => { + const ext = { + name: 'freemind', + mimes: ['application/x-freemind'], + encode: null, + decode: vi.fn().mockResolvedValue('decoded'), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.mm' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/x-freemind', + writeable: true, + mtime: 1, + }, + }) + + FilesMindMap.load(vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.supportedWrite).toBe(false) + }) + }) +}) diff --git a/src/__tests__/plugins/freemind.spec.js b/src/__tests__/plugins/freemind.spec.js new file mode 100644 index 0000000..962fab5 --- /dev/null +++ b/src/__tests__/plugins/freemind.spec.js @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import util from '../../util.js' +import freemind from '../../plugins/freemind.js' + +// freemind.toKm references the legacy global FilesMindMap.Util +beforeAll(() => { + global.FilesMindMap = { Util: util } +}) + +describe('freemind plugin', () => { + it('has the correct name', () => { + expect(freemind.name).toBe('freemind') + }) + + it('supports application/x-freemind mime type', () => { + expect(freemind.mimes).toContain('application/x-freemind') + }) + + it('encode is null (read-only format)', () => { + expect(freemind.encode).toBeNull() + }) + + describe('markerMap', () => { + it('maps full-1 to priority 1', () => { + expect(freemind.markerMap['full-1']).toEqual(['priority', 1]) + }) + + it('maps full-8 to priority 8', () => { + expect(freemind.markerMap['full-8']).toEqual(['priority', 8]) + }) + }) + + describe('processTopic', () => { + it('extracts TEXT as data.text', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Hello' }, obj) + expect(obj.data.text).toBe('Hello') + }) + + it('extracts hyperlink from LINK attribute', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Link', LINK: 'https://example.com' }, obj) + expect(obj.data.hyperlink).toBe('https://example.com') + }) + + it('extracts a single priority marker', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Prio', icon: { BUILTIN: 'full-3' } }, obj) + expect(obj.data.priority).toBe(3) + }) + + it('extracts multiple markers from an array', () => { + const obj = {} + freemind.processTopic({ + TEXT: 'Multi', + icon: [{ BUILTIN: 'full-1' }, { BUILTIN: 'full-2' }], + }, obj) + expect(obj.data.priority).toBe(2) // last one wins + }) + + it('ignores unknown marker values', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Unknown', icon: { BUILTIN: 'unknown-marker' } }, obj) + expect(obj.data.priority).toBeUndefined() + }) + + it('processes a single child node', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Parent', node: { TEXT: 'Child' } }, obj) + expect(obj.children).toHaveLength(1) + expect(obj.children[0].data.text).toBe('Child') + }) + + it('processes multiple child nodes', () => { + const obj = {} + freemind.processTopic({ + TEXT: 'Parent', + node: [{ TEXT: 'Child1' }, { TEXT: 'Child2' }], + }, obj) + expect(obj.children).toHaveLength(2) + expect(obj.children[0].data.text).toBe('Child1') + expect(obj.children[1].data.text).toBe('Child2') + }) + + it('produces no children property when topic has no children', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Leaf' }, obj) + expect(obj.children).toBeUndefined() + }) + }) + + describe('decode', () => { + it('decodes a FreeMind XML string into a km-compatible tree', async () => { + const xml = '' + const result = await freemind.decode(xml) + expect(result.data.text).toBe('Root') + expect(result.children).toHaveLength(1) + expect(result.children[0].data.text).toBe('Child') + }) + + it('rejects on malformed input', async () => { + await expect(freemind.decode(null)).rejects.toBeDefined() + }) + }) +}) diff --git a/src/__tests__/plugins/km.spec.js b/src/__tests__/plugins/km.spec.js new file mode 100644 index 0000000..1390ed5 --- /dev/null +++ b/src/__tests__/plugins/km.spec.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import km from '../../plugins/km.js' + +describe('km plugin', () => { + it('has the correct name', () => { + expect(km.name).toBe('km') + }) + + it('supports application/km mime type', () => { + expect(km.mimes).toContain('application/km') + }) + + describe('encode', () => { + it('resolves with the same data unchanged', async () => { + const data = '{"root":{"data":{"text":"Test"}}}' + await expect(km.encode(data)).resolves.toBe(data) + }) + + it('resolves with empty string', async () => { + await expect(km.encode('')).resolves.toBe('') + }) + }) + + describe('decode', () => { + it('parses a valid JSON string and resolves with the object', async () => { + const obj = { root: { data: { text: 'Test' } } } + const result = await km.decode(JSON.stringify(obj)) + expect(result).toEqual(obj) + }) + + it('resolves with the raw string when JSON is invalid', async () => { + const data = 'not-valid-json' + await expect(km.decode(data)).resolves.toBe(data) + }) + + it('resolves with empty string', async () => { + await expect(km.decode('')).resolves.toBe('') + }) + }) +}) diff --git a/src/__tests__/plugins/xmind.spec.js b/src/__tests__/plugins/xmind.spec.js new file mode 100644 index 0000000..2a0059c --- /dev/null +++ b/src/__tests__/plugins/xmind.spec.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import xmind from '../../plugins/xmind.js' + +describe('xmind plugin', () => { + it('has the correct name', () => { + expect(xmind.name).toBe('xmind') + }) + + it('supports application/vnd.xmind.workbook mime type', () => { + expect(xmind.mimes).toContain('application/vnd.xmind.workbook') + }) + + it('encode is null (read-only format)', () => { + expect(xmind.encode).toBeNull() + }) + + describe('markerMap', () => { + it('maps priority-1 to priority 1', () => { + expect(xmind.markerMap['priority-1']).toEqual(['priority', 1]) + }) + + it('maps priority-8 to priority 8', () => { + expect(xmind.markerMap['priority-8']).toEqual(['priority', 8]) + }) + + it('maps task-done to progress 9', () => { + expect(xmind.markerMap['task-done']).toEqual(['progress', 9]) + }) + + it('maps task-half to progress 5', () => { + expect(xmind.markerMap['task-half']).toEqual(['progress', 5]) + }) + }) + + describe('processTopic', () => { + it('extracts title as data.text', () => { + const obj = {} + xmind.processTopic({ title: 'My Topic' }, obj) + expect(obj.data.text).toBe('My Topic') + }) + + it('extracts hyperlink from xlink:href', () => { + const obj = {} + xmind.processTopic({ title: 'Link', 'xlink:href': 'https://example.com' }, obj) + expect(obj.data.hyperlink).toBe('https://example.com') + }) + + it('extracts a single marker', () => { + const obj = {} + xmind.processTopic({ + title: 'Prio', + marker_refs: { marker_ref: { marker_id: 'priority-2' } }, + }, obj) + expect(obj.data.priority).toBe(2) + }) + + it('extracts multiple markers from an array', () => { + const obj = {} + xmind.processTopic({ + title: 'Multi', + marker_refs: { + marker_ref: [ + { marker_id: 'priority-3' }, + { marker_id: 'task-done' }, + ], + }, + }, obj) + expect(obj.data.priority).toBe(3) + expect(obj.data.progress).toBe(9) + }) + + it('ignores unknown markers', () => { + const obj = {} + xmind.processTopic({ + title: 'Unknown', + marker_refs: { marker_ref: { marker_id: 'unknown-marker' } }, + }, obj) + expect(obj.data.priority).toBeUndefined() + }) + + it('processes multiple child topics', () => { + const obj = {} + xmind.processTopic({ + title: 'Root', + children: { + topics: { + topic: [{ title: 'Child1' }, { title: 'Child2' }], + }, + }, + }, obj) + expect(obj.children).toHaveLength(2) + expect(obj.children[0].data.text).toBe('Child1') + expect(obj.children[1].data.text).toBe('Child2') + }) + + it('produces no children property for leaf topics', () => { + const obj = {} + xmind.processTopic({ title: 'Leaf' }, obj) + expect(obj.children).toBeUndefined() + }) + }) + + describe('readDocument', () => { + it('rejects when content.xml is missing in the zip', async () => { + await expect(xmind.readDocument('not a zip')).rejects.toBeDefined() + }) + }) +}) diff --git a/src/__tests__/util.spec.js b/src/__tests__/util.spec.js new file mode 100644 index 0000000..e8cff23 --- /dev/null +++ b/src/__tests__/util.spec.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import util from '../util.js' + +describe('util.jsVar', () => { + it('replaces hyphens with underscores', () => { + expect(util.jsVar('foo-bar-baz')).toBe('foo_bar_baz') + }) + + it('returns empty string for null', () => { + expect(util.jsVar(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(util.jsVar(undefined)).toBe('') + }) + + it('leaves strings without hyphens unchanged', () => { + expect(util.jsVar('foobar')).toBe('foobar') + }) +}) + +describe('util.toArray', () => { + it('wraps a non-array value in an array', () => { + expect(util.toArray('foo')).toEqual(['foo']) + expect(util.toArray(42)).toEqual([42]) + }) + + it('wraps an object in an array', () => { + const obj = { a: 1 } + expect(util.toArray(obj)).toEqual([obj]) + }) + + it('returns arrays unchanged', () => { + expect(util.toArray([1, 2, 3])).toEqual([1, 2, 3]) + }) + + it('returns empty arrays unchanged', () => { + expect(util.toArray([])).toEqual([]) + }) +}) + +describe('util.base64Encode / util.base64Decode', () => { + it('round-trips ASCII text', () => { + const text = 'Hello, World!' + expect(util.base64Decode(util.base64Encode(text))).toBe(text) + }) + + it('round-trips unicode text', () => { + const text = 'Héllo Wörld — こんにちは' + expect(util.base64Decode(util.base64Encode(text))).toBe(text) + }) + + it('base64Encode returns a non-empty string', () => { + expect(util.base64Encode('test')).toBeTruthy() + }) + + it('base64Decode handles plain btoa-encoded ASCII', () => { + expect(util.base64Decode(btoa('hello'))).toBe('hello') + }) +}) + +describe('util.xml2json', () => { + it('parses element attributes', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result.id).toBe('42') + expect(result.name).toBe('test') + }) + + it('parses nested child elements', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result.child).toBeDefined() + expect(result.child.TEXT).toBe('hello') + }) + + it('parses multiple children of the same tag as an array', () => { + const xml = '' + const result = util.xml2json(xml) + expect(Array.isArray(result.node)).toBe(true) + expect(result.node).toHaveLength(2) + }) + + it('returns defined result for a minimal document', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result).toBeDefined() + }) +}) diff --git a/src/mindmap.js b/src/mindmap.js index d277615..2e3367c 100644 --- a/src/mindmap.js +++ b/src/mindmap.js @@ -1,19 +1,20 @@ -import { basename, extname } from 'path' +/* global OCA */ +// eslint-disable-next-line import/no-unresolved import SvgPencil from '@mdi/svg/svg/pencil.svg?raw' +// eslint-disable-next-line import/no-unresolved import MindMapSvg from '../img/mindmap.svg?raw' import { DefaultType, - addNewFileMenuEntry, registerFileAction, File, Permission, - getUniqueName + getUniqueName, } from '@nextcloud/files' import { FileAction, registerFileAction as legacyRegisterFileAction, - addNewFileMenuEntry as legacyAddNewFileMenuEntry + addNewFileMenuEntry as legacyAddNewFileMenuEntry, } from '@nextcloud/files-legacy' import { emit } from '@nextcloud/event-bus' import axios from '@nextcloud/axios' @@ -24,100 +25,99 @@ import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { showMessage as showToast } from '@nextcloud/dialogs' - -import util from './util' -import km from './plugins/km' -import freemind from './plugins/freemind' -import xmind from './plugins/xmind' +import util from './util.js' +import km from './plugins/km.js' +import freemind from './plugins/freemind.js' +import xmind from './plugins/xmind.js' const version = Number.parseInt((window.OC?.config?.version ?? '0').split('.')[0]) -var FilesMindMap = { +const FilesMindMap = { _currentContext: null, _file: {}, _lastTitle: '', _extensions: [], - init: function() { - this.registerExtension([km, freemind, xmind]); + init() { + this.registerExtension([km, freemind, xmind]) }, - registerExtension: function(objs) { - var self = this; + registerExtension(objs) { + const self = this if (!Array.isArray(objs)) { - objs = [objs]; + objs = [objs] } - objs.forEach(function(obj){ - self._extensions.push(obj); - }); + objs.forEach(function(obj) { + self._extensions.push(obj) + }) }, - getExtensionByMime: function(mime) { - for (var i = 0; i < this._extensions.length; i++) { - var obj = this._extensions[i]; + getExtensionByMime(mime) { + for (let i = 0; i < this._extensions.length; i++) { + const obj = this._extensions[i] if (obj.mimes.indexOf(mime) >= 0) { - return obj; + return obj } } - return null; + return null }, - isSupportedMime: function(mime) { - return this.getExtensionByMime(mime) !== null ? true : false; + isSupportedMime(mime) { + return this.getExtensionByMime(mime) !== null }, - showMessage: function(msg, delay) { - delay = delay || 3000; - return showToast(msg, { timeout: delay }); + showMessage(msg, delay) { + delay = delay || 3000 + return showToast(msg, { timeout: delay }) }, - hideMessage: function(toast) { + hideMessage(toast) { if (toast && typeof toast.hideToast === 'function') { - toast.hideToast(); + toast.hideToast() } }, /** * Determine if this page is public mindmap share page - * @returns {boolean} + * @return {boolean} */ - isMindmapPublic: function() { + isMindmapPublic() { if (!isPublicShare()) { - return false; + return false } - return this.isSupportedMime(document.getElementById('mimetype')?.value); - }, + return this.isSupportedMime(document.getElementById('mimetype')?.value) + }, - save: function(data, success, fail) { - var self = this; - var url = ''; - var path = this._file.dir + '/' + this._file.name; + save(data, success, fail) { + const self = this + let url = '' + let path = this._file.dir + '/' + this._file.name if (this._file.dir === '/') { - path = '/' + this._file.name; + path = '/' + this._file.name } /* 当encode方法没实现的时候无法保存 */ - var plugin = this.getExtensionByMime(this._file.mime); + const plugin = this.getExtensionByMime(this._file.mime) if (plugin.encode === null) { - fail(t('files_mindmap', 'Does not support saving {extension} files.', {extension: plugin.name})); - return; + fail(t('files_mindmap', 'Does not support saving {extension} files.', { extension: plugin.name })) + return } plugin.encode(data).then(function(data2) { - var putObject = { + const putObject = { filecontents: data2, - path: path, - mtime: self._file.mtime // send modification time of currently loaded file - }; + path, + mtime: self._file.mtime, // send modification time of currently loaded file + } if (document.getElementById('isPublic')?.value) { - putObject.token = document.getElementById('sharingToken')?.value; - url = generateUrl('/apps/files_mindmap/share/save'); + putObject.token = document.getElementById('sharingToken')?.value + url = generateUrl('/apps/files_mindmap/share/save') if (self.isSupportedMime(document.getElementById('mimetype')?.value)) { - putObject.path = ''; + putObject.path = '' } } else { - url = generateUrl('/apps/files_mindmap/ajax/savefile'); + url = generateUrl('/apps/files_mindmap/ajax/savefile') } axios({ @@ -127,72 +127,71 @@ var FilesMindMap = { }).then(function(response) { // update modification time try { - self._file.mtime = response.data.mtime; - } catch(e) {} - success(t('files_mindmap', 'File Saved')); + self._file.mtime = response.data.mtime + } catch (e) {} + success(t('files_mindmap', 'File Saved')) }).catch(function(error) { - var message = t('files_mindmap', 'Save failed'); - message = error.response.data.message; - fail(message); - }); - }); + const message = error.response?.data?.message || t('files_mindmap', 'Save failed') + fail(message) + }) + }) }, - load: function(success, failure) { - var self = this; - var filename = this._file.name; - var dir = this._file.dir; - var url = ''; - var sharingToken = ''; - var mimetype = document.getElementById('mimetype')?.value; + load(success, failure) { + const self = this + const filename = this._file.name + const dir = this._file.dir + let url = '' + let sharingToken = '' + const mimetype = document.getElementById('mimetype')?.value if (document.getElementById('isPublic')?.value && this.isSupportedMime(mimetype)) { - sharingToken = document.getElementById('sharingToken')?.value; - url = generateUrl('/apps/files_mindmap/public/{token}', {token: sharingToken}); + sharingToken = document.getElementById('sharingToken')?.value + url = generateUrl('/apps/files_mindmap/public/{token}', { token: sharingToken }) } else if (document.getElementById('isPublic')?.value) { - sharingToken = document.getElementById('sharingToken')?.value; + sharingToken = document.getElementById('sharingToken')?.value url = generateUrl('/apps/files_mindmap/public/{token}?dir={dir}&filename={filename}', - { token: sharingToken, filename: filename, dir: dir}); + { token: sharingToken, filename, dir }) } else { url = generateUrl('/apps/files_mindmap/ajax/loadfile?filename={filename}&dir={dir}', - {filename: filename, dir: dir}); + { filename, dir }) } axios.get(url).then(function(response) { - var data = response.data; - data.filecontents = util.base64Decode(data.filecontents); - var plugin = self.getExtensionByMime(data.mime); + const data = response.data + data.filecontents = util.base64Decode(data.filecontents) + const plugin = self.getExtensionByMime(data.mime) if (!plugin || plugin.decode === null) { - failure(t('files_mindmap', 'Unsupported file type: {mimetype}', {mimetype: data.mime})); - return; + failure(t('files_mindmap', 'Unsupported file type: {mimetype}', { mimetype: data.mime })) + return } - plugin.decode(data.filecontents).then(function(kmdata){ - data.filecontents = typeof kmdata === 'object' ? JSON.stringify(kmdata) : kmdata; - data.supportedWrite = true; + plugin.decode(data.filecontents).then(function(kmdata) { + data.filecontents = typeof kmdata === 'object' ? JSON.stringify(kmdata) : kmdata + data.supportedWrite = true if (plugin.encode === null) { - data.writeable = false; - data.supportedWrite = false; + data.writeable = false + data.supportedWrite = false } - self._file.writeable = data.writeable; - self._file.supportedWrite = data.supportedWrite; - self._file.mime = data.mime; - self._file.mtime = data.mtime; + self._file.writeable = data.writeable + self._file.supportedWrite = data.supportedWrite + self._file.mime = data.mime + self._file.mtime = data.mtime - success(data.filecontents); - }, function(e){ - failure(e); + success(data.filecontents) + }, function(e) { + failure(e) }) }).catch(function(error) { - failure(error.response?.data?.message || error.message); - }); + failure(error.response?.data?.message || error.message) + }) }, /** * @private */ - registerFileActions: function () { - var mimes = this.getSupportedMimetypes(), - _self = this; + registerFileActions() { + const mimes = this.getSupportedMimetypes() + const _self = this const actionConfig = { id: 'file_mindmap', @@ -207,7 +206,7 @@ var FilesMindMap = { async exec(node, view) { try { - OCA.Viewer.openWith('mindmap', { path: node.path }); + OCA.Viewer.openWith('mindmap', { path: node.path }) return true } catch (error) { _self.showMessage(error) @@ -225,19 +224,19 @@ var FilesMindMap = { } }, - registerNewFileMenuPlugin: function() { + registerNewFileMenuPlugin() { legacyAddNewFileMenuEntry({ id: 'mindmapfile', displayName: t('files_mindmap', 'New mind map file'), ...(version >= 33 ? { iconSvgInline: MindMapSvg } : { iconClass: 'icon-mindmap' }), enabled(context) { // only attach to main file list, public view is not supported yet - console.log('addNewFileMenuEntry', context); - return (context.permissions & Permission.CREATE) !== 0; + console.debug('addNewFileMenuEntry', context) + return (context.permissions & Permission.CREATE) !== 0 }, async handler(context, content) { const contentNames = content.map((node) => node.basename) - const fileName = getUniqueName(t('files_mindmap', "New mind map.km"), contentNames) + const fileName = getUniqueName(t('files_mindmap', 'New mind map.km'), contentNames) const source = context.encodedSource + '/' + encodeURIComponent(fileName) const response = await axios({ @@ -264,33 +263,33 @@ var FilesMindMap = { emit('files:node:created', file) - OCA.Viewer.openWith('mindmap', { path: file.path }); + OCA.Viewer.openWith('mindmap', { path: file.path }) }, - }); + }) }, - setFile: function(file) { - let filename = file.filename + ''; - let basename = file.basename + ''; + setFile(file) { + const filename = file.filename + '' + const basename = file.basename + '' - this._file.name = basename; - this._file.root = '/files/' + getCurrentUser()?.uid; - this._file.dir = dirname(filename); - this._file.fullName = filename; + this._file.name = basename + this._file.root = '/files/' + getCurrentUser()?.uid + this._file.dir = dirname(filename) + this._file.fullName = filename this._currentContext = { dir: this._file.dir, - root: this._file.root + root: this._file.root, } }, - getSupportedMimetypes: function() { - var result = []; - this._extensions.forEach(function(obj){ - result = result.concat(obj.mimes); - }); - console.debug('Mindmap Mimetypes:', result); - return result; + getSupportedMimetypes() { + let result = [] + this._extensions.forEach(function(obj) { + result = result.concat(obj.mimes) + }) + console.debug('Mindmap Mimetypes:', result) + return result }, -}; +} -export default FilesMindMap; +export default FilesMindMap diff --git a/src/mindmapviewer.js b/src/mindmapviewer.js index 4ab6508..ccd12d3 100644 --- a/src/mindmapviewer.js +++ b/src/mindmapviewer.js @@ -1,5 +1,6 @@ +/* global OCA */ import MindMap from './views/MindMap.vue' -import FilesMindMap from './mindmap' +import FilesMindMap from './mindmap.js' OCA.FilesMindMap = FilesMindMap diff --git a/src/plugins/freemind.js b/src/plugins/freemind.js index a1d29f1..11ebdab 100644 --- a/src/plugins/freemind.js +++ b/src/plugins/freemind.js @@ -1,77 +1,78 @@ +/* global FilesMindMap */ export default { name: 'freemind', mimes: ['application/x-freemind'], encode: null, - decode: function(data) { - var self = this; - return new Promise(function(resolve, reject) { - try { - var result = self.toKm(data); - resolve(result); - } catch (e) { - reject(e); - } - }); + decode(data) { + const self = this + return new Promise(function(resolve, reject) { + try { + const result = self.toKm(data) + resolve(result) + } catch (e) { + reject(e) + } + }) }, - markerMap: { - 'full-1': ['priority', 1], - 'full-2': ['priority', 2], - 'full-3': ['priority', 3], - 'full-4': ['priority', 4], - 'full-5': ['priority', 5], - 'full-6': ['priority', 6], - 'full-7': ['priority', 7], - 'full-8': ['priority', 8] - }, - processTopic: function (topic, obj) { - //处理文本 - obj.data = { - text: topic.TEXT - }; - var i; + markerMap: { + 'full-1': ['priority', 1], + 'full-2': ['priority', 2], + 'full-3': ['priority', 3], + 'full-4': ['priority', 4], + 'full-5': ['priority', 5], + 'full-6': ['priority', 6], + 'full-7': ['priority', 7], + 'full-8': ['priority', 8], + }, + processTopic(topic, obj) { + // 处理文本 + obj.data = { + text: topic.TEXT, + } + let i - // 处理标签 - if (topic.icon) { - var icons = topic.icon; - var type; - if (icons.length && icons.length > 0) { - for (i in icons) { - type = this.markerMap[icons[i].BUILTIN]; - if (type) obj.data[type[0]] = type[1]; - } - } else { - type = this.markerMap[icons.BUILTIN]; - if (type) obj.data[type[0]] = type[1]; - } - } + // 处理标签 + if (topic.icon) { + const icons = topic.icon + let type + if (icons.length && icons.length > 0) { + for (i in icons) { + type = this.markerMap[icons[i].BUILTIN] + if (type) obj.data[type[0]] = type[1] + } + } else { + type = this.markerMap[icons.BUILTIN] + if (type) obj.data[type[0]] = type[1] + } + } - // 处理超链接 - if (topic.LINK) { - obj.data.hyperlink = topic.LINK; - } + // 处理超链接 + if (topic.LINK) { + obj.data.hyperlink = topic.LINK + } - //处理子节点 - if (topic.node) { - var tmp = topic.node; - if (tmp.length && tmp.length > 0) { //多个子节点 - obj.children = []; + // 处理子节点 + if (topic.node) { + const tmp = topic.node + if (tmp.length && tmp.length > 0) { // 多个子节点 + obj.children = [] - for (i in tmp) { - obj.children.push({}); - this.processTopic(tmp[i], obj.children[i]); - } + for (i in tmp) { + obj.children.push({}) + this.processTopic(tmp[i], obj.children[i]) + } - } else { //一个子节点 - obj.children = [{}]; - this.processTopic(tmp, obj.children[0]); - } - } - }, - toKm: function (xml) { - var json = FilesMindMap.Util.xml2json(xml); - var result = {}; - this.processTopic(json.node, result); - return result; - } + } else { // 一个子节点 + obj.children = [{}] + this.processTopic(tmp, obj.children[0]) + } + } + }, + toKm(xml) { + const json = FilesMindMap.Util.xml2json(xml) + const result = {} + this.processTopic(json.node, result) + return result + }, -} \ No newline at end of file +} diff --git a/src/plugins/xmind.js b/src/plugins/xmind.js index a4419ef..91275fa 100644 --- a/src/plugins/xmind.js +++ b/src/plugins/xmind.js @@ -1,106 +1,106 @@ import JSZip from 'jszip' -import util from '../util' +import util from '../util.js' export default { name: 'xmind', mimes: ['application/vnd.xmind.workbook'], encode: null, - decode: function(data) { - return this.readDocument(data); + decode(data) { + return this.readDocument(data) }, - markerMap : { - 'priority-1': ['priority', 1], - 'priority-2': ['priority', 2], - 'priority-3': ['priority', 3], - 'priority-4': ['priority', 4], - 'priority-5': ['priority', 5], - 'priority-6': ['priority', 6], - 'priority-7': ['priority', 7], - 'priority-8': ['priority', 8], + markerMap: { + 'priority-1': ['priority', 1], + 'priority-2': ['priority', 2], + 'priority-3': ['priority', 3], + 'priority-4': ['priority', 4], + 'priority-5': ['priority', 5], + 'priority-6': ['priority', 6], + 'priority-7': ['priority', 7], + 'priority-8': ['priority', 8], - 'task-start': ['progress', 1], - 'task-oct': ['progress', 2], - 'task-quarter': ['progress', 3], - 'task-3oct': ['progress', 4], - 'task-half': ['progress', 5], - 'task-5oct': ['progress', 6], - 'task-3quar': ['progress', 7], - 'task-7oct': ['progress', 8], - 'task-done': ['progress', 9] - }, - processTopic: function (topic, obj) { + 'task-start': ['progress', 1], + 'task-oct': ['progress', 2], + 'task-quarter': ['progress', 3], + 'task-3oct': ['progress', 4], + 'task-half': ['progress', 5], + 'task-5oct': ['progress', 6], + 'task-3quar': ['progress', 7], + 'task-7oct': ['progress', 8], + 'task-done': ['progress', 9], + }, + processTopic(topic, obj) { - //处理文本 - obj.data = { - text: topic.title - }; + // 处理文本 + obj.data = { + text: topic.title, + } - // 处理标签 - if (topic.marker_refs && topic.marker_refs.marker_ref) { - var markers = topic.marker_refs.marker_ref; - var type; - if (markers.length && markers.length > 0) { - for (var i in markers) { - type = this.markerMap[markers[i].marker_id]; - if (type) obj.data[type[0]] = type[1]; - } - } else { - type = this.markerMap[markers.marker_id]; - if (type) obj.data[type[0]] = type[1]; - } - } + // 处理标签 + if (topic.marker_refs && topic.marker_refs.marker_ref) { + const markers = topic.marker_refs.marker_ref + let type + if (markers.length && markers.length > 0) { + for (const i in markers) { + type = this.markerMap[markers[i].marker_id] + if (type) obj.data[type[0]] = type[1] + } + } else { + type = this.markerMap[markers.marker_id] + if (type) obj.data[type[0]] = type[1] + } + } - // 处理超链接 - if (topic['xlink:href']) { - obj.data.hyperlink = topic['xlink:href']; - } - //处理子节点 - var topics = topic.children && topic.children.topics; - var subTopics = topics && (topics.topic || topics[0] && topics[0].topic); - if (subTopics) { - var tmp = subTopics; - if (tmp.length && tmp.length > 0) { //多个子节点 - obj.children = []; + // 处理超链接 + if (topic['xlink:href']) { + obj.data.hyperlink = topic['xlink:href'] + } + // 处理子节点 + const topics = topic.children && topic.children.topics + const subTopics = topics && (topics.topic || (topics[0] && topics[0].topic)) + if (subTopics) { + const tmp = subTopics + if (tmp.length && tmp.length > 0) { // 多个子节点 + obj.children = [] - for (var ii in tmp) { - obj.children.push({}); - this.processTopic(tmp[ii], obj.children[ii]); - } + for (const ii in tmp) { + obj.children.push({}) + this.processTopic(tmp[ii], obj.children[ii]) + } - } else { //一个子节点 - obj.children = [{}]; - this.processTopic(tmp, obj.children[0]); - } - } - }, - toKm: function (xml) { - var json = util.xml2json(xml); - var result = {}; - var sheet = json.sheet; - var topic = Array.isArray(sheet) ? sheet[0].topic : sheet.topic; - this.processTopic(topic, result); - return result; - }, - readDocument: function (file) { - var self = this; - return new Promise(function(resolve, reject) { - JSZip.loadAsync(file).then(function(zip){ - var contentFile = zip.file('content.xml'); - if (contentFile != null) { - contentFile.async('text').then(function(text){ - try { - var json = self.toKm(text); - resolve(json); - } catch (e) { - reject(e); - } - }); - } else { - reject(new Error('Content document missing')); - } - }, function(e) { - reject(e); - }); - }); - } -} \ No newline at end of file + } else { // 一个子节点 + obj.children = [{}] + this.processTopic(tmp, obj.children[0]) + } + } + }, + toKm(xml) { + const json = util.xml2json(xml) + const result = {} + const sheet = json.sheet + const topic = Array.isArray(sheet) ? sheet[0].topic : sheet.topic + this.processTopic(topic, result) + return result + }, + readDocument(file) { + const self = this + return new Promise(function(resolve, reject) { + JSZip.loadAsync(file).then(function(zip) { + const contentFile = zip.file('content.xml') + if (contentFile != null) { + contentFile.async('text').then(function(text) { + try { + const json = self.toKm(text) + resolve(json) + } catch (e) { + reject(e) + } + }) + } else { + reject(new Error('Content document missing')) + } + }, function(e) { + reject(e) + }) + }) + }, +} diff --git a/src/public.js b/src/public.js index 3a3de6f..469a6ac 100644 --- a/src/public.js +++ b/src/public.js @@ -1,45 +1,40 @@ -import FilesMindMap from './mindmap' -import logger from './logger' +/* global OCA */ +import FilesMindMap from './mindmap.js' +import logger from './logger.js' -import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { isPublicShare } from '@nextcloud/sharing/public' if (isPublicShare()) { - OCA.FilesMindMap = FilesMindMap; - FilesMindMap.init(); - - if (FilesMindMap.isMindmapPublic()) { - window.addEventListener('DOMContentLoaded', function() { - var sharingToken = getSharingToken(); - var downloadUrl = OC.generateUrl('/s/{token}/download', {token: sharingToken}); - var viewer = OCA.FilesMindMap; - - - const contentElmt = document.getElementById('files-public-content'); - const footerElmt = document.querySelector('body > footer') || document.querySelector('#app-content > footer'); - if (contentElmt) { - if (OCA.Viewer) { - contentElmt.innerHTML = ''; - OCA.Viewer.setRootElement('#files-public-content') - OCA.Viewer.open({ path: '/' }); - - footerElmt.style.display = 'none'; - - // This is an ugly implementation, need to remove the top margin after viewer creates the iframe - setTimeout(() => { - const frameElmt = document.querySelector('#viewer > iframe'); - if (frameElmt) { - frameElmt.style.marginTop = '0'; - } - }, 1000); - - } else { - logger.error('Viewer is not available, cannot preview mindmap'); - } - } - - }); - } - - - console.log('files_mindmap public.js loaded'); -} \ No newline at end of file + OCA.FilesMindMap = FilesMindMap + FilesMindMap.init() + + if (FilesMindMap.isMindmapPublic()) { + window.addEventListener('DOMContentLoaded', function() { + const contentElmt = document.getElementById('files-public-content') + const footerElmt = document.querySelector('body > footer') || document.querySelector('#app-content > footer') + if (contentElmt) { + if (OCA.Viewer) { + contentElmt.innerHTML = '' + OCA.Viewer.setRootElement('#files-public-content') + OCA.Viewer.open({ path: '/' }) + + footerElmt.style.display = 'none' + + // This is an ugly implementation, need to remove the top margin after viewer creates the iframe + setTimeout(() => { + const frameElmt = document.querySelector('#viewer > iframe') + if (frameElmt) { + frameElmt.style.marginTop = '0' + } + }, 1000) + + } else { + logger.error('Viewer is not available, cannot preview mindmap') + } + } + + }) + } + + console.debug('files_mindmap public.js loaded') +} diff --git a/src/util.js b/src/util.js index 8a04603..526c619 100644 --- a/src/util.js +++ b/src/util.js @@ -1,104 +1,101 @@ export default { - base64Encode: function(string) { + base64Encode(string) { return btoa(encodeURIComponent(string).replace(/%([0-9A-F]{2})/g, function(match, p1) { return String.fromCharCode(parseInt(p1, 16)) })) }, - base64Decode: function(base64) { + base64Decode(base64) { try { return decodeURIComponent(Array.prototype.map.call(atob(base64), function(c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) - }).join('')); + }).join('')) } catch (e) { - var binary = atob(base64); - var array = new Uint8Array(binary.length); - for (var i = 0; i < binary.length; i++) { - array[i] = binary.charCodeAt(i); + const binary = atob(base64) + const array = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i) } - return new Blob([array]); + return new Blob([array]) } }, - jsVar: function (s) { - return String(s || '').replace(/-/g,"_"); - }, - toArray: function (obj){ - if (!Array.isArray(obj)) { - return [obj]; - } - return obj; - }, - parseNode: function (node) { - if (!node) return null; - var self = this; - var txt = '', obj = null, att = null; + jsVar(s) { + return String(s || '').replace(/-/g, '_') + }, + toArray(obj) { + if (!Array.isArray(obj)) { + return [obj] + } + return obj + }, + parseNode(node) { + if (!node) return null + const self = this + let txt = ''; let obj = null; let att = null - if (node.childNodes) { - if (node.childNodes.length > 0) { - node.childNodes.forEach(function(cn) { - var cnt = cn.nodeType, cnn = self.jsVar(cn.localName || cn.nodeName); - var cnv = cn.text || cn.nodeValue || ''; + if (node.childNodes) { + if (node.childNodes.length > 0) { + node.childNodes.forEach(function(cn) { + const cnt = cn.nodeType; const cnn = self.jsVar(cn.localName || cn.nodeName) + const cnv = cn.text || cn.nodeValue || '' - /* comment */ - if (cnt == 8) { - return; // ignore comment node - } - /* white-space */ - else if (cnt == 3 || cnt == 4 || !cnn) { - if (cnv.match(/^\s+$/)) { - return; - } - txt += cnv.replace(/^\s+/, '').replace(/\s+$/, ''); - } else { - obj = obj || {}; - if (obj[cnn]) { - if (!obj[cnn].length) { - obj[cnn] = self.toArray(obj[cnn]); - } - obj[cnn] = self.toArray(obj[cnn]); + if (cnt === 8) { + // comment node — ignore + } else if (cnt === 3 || cnt === 4 || !cnn) { + // white-space + if (cnv.match(/^\s+$/)) { + return + } + txt += cnv.replace(/^\s+/, '').replace(/\s+$/, '') + } else { + obj = obj || {} + if (obj[cnn]) { + if (!obj[cnn].length) { + obj[cnn] = self.toArray(obj[cnn]) + } + obj[cnn] = self.toArray(obj[cnn]) - obj[cnn].push(self.parseNode(cn, true)); - } else { - obj[cnn] = self.parseNode(cn); - } - } - }); - } - } - if (node.attributes && node.tagName !='title') { - if (node.attributes.length > 0) { - att = {}; obj = obj || {}; - node.attributes.forEach = [].forEach.bind(node.attributes); - node.attributes.forEach(function (at) { - var atn = self.jsVar(at.name), atv = at.value; - att[atn] = atv; - if (obj[atn]) { - obj[cnn] = this.toArray(obj[cnn]); - obj[atn][obj[atn].length] = atv; - } - else { - obj[atn] = atv; - } - }); - } - } - if (obj) { - obj = Object.assign({}, (txt != '' ? new String(txt) : {}), obj || {}); - txt = (obj.text) ? ([obj.text || '']).concat([txt]) : txt; - if (txt) obj.text = txt; - txt = ''; - } - var out = obj || txt; - return out; - }, - parseXML: function (xml) { - var root = (xml.nodeType == 9) ? xml.documentElement : xml; - return this.parseNode(root, true); - }, - xml2json: function (str) { - var domParser = new DOMParser(); - var dom = domParser.parseFromString(str, 'application/xml'); + obj[cnn].push(self.parseNode(cn, true)) + } else { + obj[cnn] = self.parseNode(cn) + } + } + }) + } + } + if (node.attributes && node.tagName !== 'title') { + if (node.attributes.length > 0) { + att = {}; obj = obj || {} + node.attributes.forEach = [].forEach.bind(node.attributes) + node.attributes.forEach(function(at) { + const atn = self.jsVar(at.name); const atv = at.value + att[atn] = atv + if (obj[atn]) { + obj[atn] = self.toArray(obj[atn]) + obj[atn][obj[atn].length] = atv + } else { + obj[atn] = atv + } + }) + } + } + if (obj) { + obj = Object.assign({}, (txt !== '' ? String(txt) : {}), obj || {}) + txt = (obj.text) ? ([obj.text || '']).concat([txt]) : txt + if (txt) obj.text = txt + txt = '' + } + const out = obj || txt + return out + }, + parseXML(xml) { + const root = (xml.nodeType === 9) ? xml.documentElement : xml + return this.parseNode(root, true) + }, + xml2json(str) { + const domParser = new DOMParser() + const dom = domParser.parseFromString(str, 'application/xml') - var json = this.parseXML(dom); - return json; - }, -} \ No newline at end of file + const json = this.parseXML(dom) + return json + }, +} diff --git a/src/viewer.js b/src/viewer.js index 50859b7..201df53 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1,303 +1,308 @@ - +/* global $, minder, Base64, jsPDF, angular */ +/* eslint-disable @nextcloud/no-deprecations */ /** * Checks if the page is displayed in an iframe. If not redirect to /. - **/ -function redirectIfNotDisplayedInFrame () { + */ +function redirectIfNotDisplayedInFrame() { try { if (window.frameElement) { - return; + return } } catch (e) {} - window.location.href = '/'; + window.location.href = '/' } redirectIfNotDisplayedInFrame(); (function() { - var t = function(msg) { - return window.parent.t('files_mindmap', msg); - }; + const t = function(msg) { + return window.parent.t('files_mindmap', msg) + } - var lang = window.lang || - (document.getElementById("viewer") && document.getElementById("viewer").getAttribute("lang")) || - 'en'; + const lang = window.lang + || (document.getElementById('viewer') && document.getElementById('viewer').getAttribute('lang')) + || 'en' - var MindMap = { + const MindMap = { _changed: false, _autoSaveTimer: null, _clearStatusMessageTimer: null, _loadStatus: false, - init: function() { - var self = this; + init() { + const self = this angular.module('mindmap', ['kityminderEditor']) - .config(function (configProvider) { - configProvider.set('lang', lang); + .config(function(configProvider) { + configProvider.set('lang', lang) }) .controller('MainController', function($scope) { $scope.initEditor = function(editor, minder) { - window.editor = editor; - window.minder = minder; + window.editor = editor + window.minder = minder - self.initHotkey(); - self.bindEvent(); - self.loadData(); - self.loadAutoSaveStatus(); - self.startAutoSaveTimer(); + self.initHotkey() + self.bindEvent() + self.loadData() + self.loadAutoSaveStatus() + self.startAutoSaveTimer() minder.on('contentchange', function() { - self._changed = true; - }); - }; - }); + self._changed = true + }) + } + }) angular.module('ui.colorpicker') - .config(function (localizeProvider) { - localizeProvider.setDefaultLang('en-us'); - }) ; + .config(function(localizeProvider) { + localizeProvider.setDefaultLang('en-us') + }) }, - initHotkey: function() { - var self = this; + initHotkey() { + const self = this $(document).keydown(function(e) { - if((e.ctrlKey || e.metaKey) && e.which === 83){ - self.save(); - e.preventDefault(); - return false; + if ((e.ctrlKey || e.metaKey) && e.which === 83) { + self.save() + e.preventDefault() + return false } - }); + }) }, - bindEvent: function() { - var self = this; - $('#export-png').click(function(){ - self.exportPNG(); - }); - $('#export-svg').click(function(){ - self.exportSVG(); - }); - $('#export-pdf').click(function(){ - self.exportPDF(); - }); - $('#export-markdown').click(function(){ - self.exportMarkdown(); - }); - $('#export-text').click(function(){ - self.exportText(); - }); + bindEvent() { + const self = this + $('#export-png').click(function() { + self.exportPNG() + }) + $('#export-svg').click(function() { + self.exportSVG() + }) + $('#export-pdf').click(function() { + self.exportPDF() + }) + $('#export-markdown').click(function() { + self.exportMarkdown() + }) + $('#export-text').click(function() { + self.exportText() + }) $('#save-button').click(function() { - self.save(); - }); + self.save() + }) }, - close: function() { - var self = this; - var doHide = function() { + close() { + const self = this + const doHide = function() { if (self._autoSaveTimer !== null) { - clearInterval(self._autoSaveTimer); + clearInterval(self._autoSaveTimer) } - window.parent.OCA.FilesMindMap.hide(); + window.parent.OCA.FilesMindMap.hide() } if (this._changed && window.parent.OCA.FilesMindMap._file.supportedWrite) { - var result = window.confirm(t('The file has not been saved. Is it saved?')); + const result = window.confirm(t('The file has not been saved. Is it saved?')) if (result) { - self.save(function(status){ + self.save(function(status) { if (status) { - doHide(); + doHide() } - }); + }) } else { - doHide(); + doHide() } } else { - doHide(); + doHide() } }, - showMessage: function(msg, delay) { - return window.parent.OCA.FilesMindMap.showMessage(msg, delay); + showMessage(msg, delay) { + return window.parent.OCA.FilesMindMap.showMessage(msg, delay) }, - hideMessage: function(id) { - return window.parent.OCA.FilesMindMap.hideMessage(id); + hideMessage(id) { + return window.parent.OCA.FilesMindMap.hideMessage(id) }, - setStatusMessage: function(msg) { - this.showMessage(msg); + setStatusMessage(msg) { + this.showMessage(msg) }, - updateSaveButtonInfo: function(msg) { - $('#save-button').html(msg); + updateSaveButtonInfo(msg) { + $('#save-button').html(msg) }, - restoreSaveButtonInfo: function(time) { - var self = this; - setTimeout(function(){ - self.updateSaveButtonInfo(t('Save')); - }, time); + restoreSaveButtonInfo(time) { + const self = this + setTimeout(function() { + self.updateSaveButtonInfo(t('Save')) + }, time) }, - save: function(callback) { - var self = this; + save(onComplete) { + const self = this if (self._changed) { - self.updateSaveButtonInfo(t('Saving...')); - var data = JSON.stringify(minder.exportJson()); - window.parent.OCA.FilesMindMap.save(data, function(msg){ - self.updateSaveButtonInfo(msg); - self._changed = false; - self.restoreSaveButtonInfo(3000); - if (undefined !== callback) { - callback(true, msg); + self.updateSaveButtonInfo(t('Saving...')) + const data = JSON.stringify(minder.exportJson()) + window.parent.OCA.FilesMindMap.save(data, function(msg) { + self.updateSaveButtonInfo(msg) + self._changed = false + self.restoreSaveButtonInfo(3000) + if (undefined !== onComplete) { + onComplete(true, msg) } - }, function(msg){ - self.updateSaveButtonInfo(msg); - self.restoreSaveButtonInfo(3000); - if (undefined !== callback) { - callback(false, msg); + }, function(msg) { + self.updateSaveButtonInfo(msg) + self.restoreSaveButtonInfo(3000) + if (undefined !== onComplete) { + onComplete(false, msg) } - }); - self.restoreSaveButtonInfo(6000); + }) + self.restoreSaveButtonInfo(6000) } }, - startAutoSaveTimer: function() { - var self = this; + startAutoSaveTimer() { + const self = this if (self._autoSaveTimer != null) { - clearInterval(self._autoSaveTimer); - self._autoSaveTimer = null; + clearInterval(self._autoSaveTimer) + self._autoSaveTimer = null } self._autoSaveTimer = setInterval(function() { if (self.getAutoSaveStatus()) { /* When file is readonly, autosave will stop working */ if (window.parent.OCA.FilesMindMap._file.writeable) { - self.save(); + self.save() } } - }, 10000); + }, 10000) }, - getAutoSaveStatus: function() { - var status = $('#autosave-checkbox').is(':checked'); + getAutoSaveStatus() { + const status = $('#autosave-checkbox').is(':checked') if (window.localStorage) { - localStorage.setItem('apps.files_mindmap.autosave', status); + localStorage.setItem('apps.files_mindmap.autosave', status) } - return status; + return status }, - loadAutoSaveStatus: function() { - var status = true; + loadAutoSaveStatus() { + let status = true if (window.localStorage) { if (localStorage.getItem('apps.files_mindmap.autosave') === 'false') { - status = false; + status = false } } - $('#autosave-checkbox').prop("checked", status); + $('#autosave-checkbox').prop('checked', status) }, - loadData: function() { - var self = this; - window.parent.OCA.FilesMindMap.load(function(data){ - var obj = {"root": - {"data": - {"id":"bopmq"+String(Math.floor(Math.random() * 9e15)).substr(0, 7), - "created":(new Date()).getTime(), - "text":t('Main Topic') + loadData() { + const self = this + window.parent.OCA.FilesMindMap.load(function(data) { + let obj = { + root: + { + data: + { + id: 'bopmq' + String(Math.floor(Math.random() * 9e15)).substr(0, 7), + created: (new Date()).getTime(), + text: t('Main Topic'), }, - "children":[] + children: [], }, - "template":"default", - "theme":"fresh-blue", - "version":"1.4.43" - }; + template: 'default', + theme: 'fresh-blue', + version: '1.4.43', + } /* 新生成的空文件 */ if (data !== ' ') { try { - obj = JSON.parse(data); - } catch (e){ - window.alert(t('This file is not a valid mind map file and may cause file ' + - 'corruption if you continue editing.')); + obj = JSON.parse(data) + } catch (e) { + window.alert(t('This file is not a valid mind map file and may cause file ' + + 'corruption if you continue editing.')) } } - minder.importJson(obj); + minder.importJson(obj) if (data === ' ') { - self._changed = true; - self.save(); + self._changed = true + self.save() } - self._loadStatus = true; - self._changed = false; + self._loadStatus = true + self._changed = false /* When file is readonly, hide autosave checkbox */ if (!window.parent.OCA.FilesMindMap._file.writeable) { - $('#autosave-div').hide(); + $('#autosave-div').hide() } /* When extension cannot write, hide save checkbox */ if (!window.parent.OCA.FilesMindMap._file.supportedWrite) { - $('#save-div').hide(); + $('#save-div').hide() } - }, function(msg){ - self._loadStatus = false; - window.alert(t('Load file fail!') + msg); - window.parent.OCA.FilesMindMap.hide(); - }); + }, function(msg) { + self._loadStatus = false + window.alert(t('Load file fail!') + msg) + window.parent.OCA.FilesMindMap.hide() + }) }, - isDataSchema: function (url) { - var i = 0, - ii = url.length; + isDataSchema(url) { + let i = 0 + const ii = url.length while (i < ii && url[i].trim() === '') { - i++; + i++ } - return url.substr(i, 5).toLowerCase() === 'data:'; + return url.substr(i, 5).toLowerCase() === 'data:' }, - download: function(url, filename) { - var obj = document.createElement('a'); - obj.href = url; - obj.download = filename; - obj.dataset.downloadurl = url; - document.body.appendChild(obj); - obj.click(); - document.body.removeChild(obj); + download(url, filename) { + const obj = document.createElement('a') + obj.href = url + obj.download = filename + obj.dataset.downloadurl = url + document.body.appendChild(obj) + obj.click() + document.body.removeChild(obj) }, - exportPNG: function () { - var self = this; - minder.exportData('png').then(function (data) { - self.download(data, 'export.png'); - }, function (data){ - console.error('export png fail', data); - }); + exportPNG() { + const self = this + minder.exportData('png').then(function(data) { + self.download(data, 'export.png') + }, function(data) { + console.error('export png fail', data) + }) }, - exportSVG: function () { - var self = this; - minder.exportData('svg').then(function (data) { - var url = 'data:image/svg+xml;base64,' + Base64.encode(data); - self.download(url, 'export.svg'); - }, function (data){ - console.error('export svg fail', data); - }); + exportSVG() { + const self = this + minder.exportData('svg').then(function(data) { + const url = 'data:image/svg+xml;base64,' + Base64.encode(data) + self.download(url, 'export.svg') + }, function(data) { + console.error('export svg fail', data) + }) }, - exportMarkdown: function () { - var self = this; - minder.exportData('markdown').then(function (data) { - var url = 'data:text/markdown;base64,' + Base64.encode(data); - self.download(url, 'export.md'); - }, function (data){ - console.error('export markdown fail', data); - }); + exportMarkdown() { + const self = this + minder.exportData('markdown').then(function(data) { + const url = 'data:text/markdown;base64,' + Base64.encode(data) + self.download(url, 'export.md') + }, function(data) { + console.error('export markdown fail', data) + }) }, - exportText: function () { - var self = this; - minder.exportData('text').then(function (data) { - var url = 'data:text/plain;base64,' + Base64.encode(data); - self.download(url, 'export.txt'); - }, function (data){ - console.error('export text fail', data); - }); + exportText() { + const self = this + minder.exportData('text').then(function(data) { + const url = 'data:text/plain;base64,' + Base64.encode(data) + self.download(url, 'export.txt') + }, function(data) { + console.error('export text fail', data) + }) }, - exportPDF: function () { - var self = this; - minder.exportData('png').then(function (data) { - var pdf = new jsPDF('p', 'mm', 'a4', false); - //pdf.addImage(data, 'png', 100, 200, 280, 210, undefined, 'none'); - pdf.addImage(data, 'PNG', 5, 10, 200, 0, undefined, 'SLOW'); - self.download(pdf.output('datauristring'), 'export.pdf'); - }, function (data){ - console.error('export png fail', data); - }); - } - }; + exportPDF() { + const self = this + minder.exportData('png').then(function(data) { + // eslint-disable-next-line new-cap + const pdf = new jsPDF('p', 'mm', 'a4', false) + // pdf.addImage(data, 'png', 100, 200, 280, 210, undefined, 'none'); + pdf.addImage(data, 'PNG', 5, 10, 200, 0, undefined, 'SLOW') + self.download(pdf.output('datauristring'), 'export.pdf') + }, function(data) { + console.error('export png fail', data) + }) + }, + } - window.MindMap = MindMap; -})(); + window.MindMap = MindMap +})() -window.MindMap.init(); +window.MindMap.init() diff --git a/src/views/MindMap.vue b/src/views/MindMap.vue index eac9efd..f6aa0af 100644 --- a/src/views/MindMap.vue +++ b/src/views/MindMap.vue @@ -1,25 +1,22 @@ @@ -80,4 +76,4 @@ iframe { margin-top: var(--header-height); position: absolute; } - \ No newline at end of file + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..66ba211 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue2' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + globals: true, + server: { + deps: { + // cancelable-promise is CJS; inline it so Vite handles interop + inline: ['cancelable-promise'], + }, + }, + }, +})