diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..9dadb01
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,5 @@
+module.exports = {
+ extends: [
+ '@nextcloud/eslint-config',
+ ],
+}
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 554c371..ef023dd 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,10 @@
"scripts": {
"build": "vite --mode production build",
"dev": "vite --mode development build",
- "watch": "vite --mode development build --watch"
+ "watch": "vite --mode development build --watch",
+ "lint": "eslint --ext .js,.vue,.ts src",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@mdi/svg": "^7.3.67",
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..d9b8931
--- /dev/null
+++ b/src/__tests__/mindmap.spec.js
@@ -0,0 +1,442 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+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', () => ({
+ 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() },
+}))
+
+import FilesMindMap from '../mindmap.js'
+import { showMessage as showToast } from '@nextcloud/dialogs'
+import axios from '@nextcloud/axios'
+
+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..916d8f5
--- /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/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'],
+ },
+ },
+ },
+})