Skip to content

Commit

Permalink
feat: Allow pasting log entries
Browse files Browse the repository at this point in the history
Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Nov 11, 2023
1 parent 29d0240 commit 6edb6a5
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 28 deletions.
20 changes: 18 additions & 2 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,36 @@

// !! Keep in sync with src/constants.ts
class Constants {
// Used config Keys

/**
* Used AppConfig Keys
* Logging levels to show, used for filtering
*/
public const CONFIG_KEY_SHOWNLEVELS = 'shownLevels';
/**
* Display format of the timestamp
*/
public const CONFIG_KEY_DATETIMEFORMAT = 'dateTimeFormat';
/**
* If relative dates should be shown for the timestamp (e.g. '3 hours ago')
*/
public const CONFIG_KEY_RELATIVEDATES = 'relativedates';
/**
* If automatic updates of the UI are enabled (polling for new entries)
*/
public const CONFIG_KEY_LIVELOG = 'liveLog';

/**
* All valid config keys
*/
public const CONFIG_KEYS = [
self::CONFIG_KEY_SHOWNLEVELS,
self::CONFIG_KEY_DATETIMEFORMAT,
self::CONFIG_KEY_RELATIVEDATES,
self::CONFIG_KEY_LIVELOG
self::CONFIG_KEY_LIVELOG,
];

// other constants
public const LOGGING_LEVELS = [0, 1, 2, 3, 4];
public const LOGGING_LEVEL_NAMES = [
'debug',
Expand Down
15 changes: 15 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ const onShowServerLog = () => {
loggingStore.loadMore()
}
/**
* Handle pressing ctrl + v to paste log entries
* @param event The keyboard event
*/
const onHandlePaste = (event: KeyboardEvent) => {
// Check Ctrl + v (be tolerant: ignore caps lock) and only intercept if target is no input for pasting
if ((event.key === 'v' || event.key === 'V') && event.ctrlKey && (event.target as HTMLElement)?.tagName !== 'INPUT') {
loggingStore.loadClipboard()
event.stopPropagation()
}
}
// Add / remove event listeners
onMounted(() => window.addEventListener('keyup', onHandlePaste))
onUnmounted(() => window.removeEventListener('keyup', onHandlePaste))
/**
* Toggle polling if live log is dis- / enabled
*/
Expand Down
13 changes: 13 additions & 0 deletions src/components/settings/SettingsActions.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<template>
<div>
<NcNoteCard type="info" class="info-note">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="t('logreader', 'You can also show log entries copied from your clipboard by pasting them on the log view using: {keyboardShortcut}', { keyboardShortcut: keyboardShortcutText }, undefined, { escape: false })" />
</NcNoteCard>
<NcButton :href="settingsStore.enabled ? downloadURL : null" :disabled="!settingsStore.enabled" download="nextcloud.log">
<template #icon>
<IconDownload :size="20" />
Expand Down Expand Up @@ -31,12 +35,16 @@ import { useLogStore } from '../../store/logging'
import { useSettingsStore } from '../../store/settings.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import IconDownload from 'vue-material-design-icons/Download.vue'
import IconUpload from 'vue-material-design-icons/Upload.vue'
const settingsStore = useSettingsStore()
const logStore = useLogStore()
// TRANSLATORS The control key abbreviation
const keyboardShortcutText = `<kbd>${t('logreader', 'Ctrl')}</kbd> + <kbd>v</kbd>`
/**
* Logfile download URL
*/
Expand Down Expand Up @@ -66,6 +74,11 @@ const onFileSelected = () => {
<style lang="scss" scoped>
div {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-inline-end: 12px;
}
.info-note {
justify-self: stretch;
}
</style>
153 changes: 129 additions & 24 deletions src/store/logging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,30 @@ import { POLLING_INTERVAL } from '../constants'
const mocks = vi.hoisted(() => {
return {
parseLogFile: vi.fn(),
parseLogString: vi.fn(),
logger: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getLog: vi.fn(),
pollLog: vi.fn(),
showError: vi.fn(),
}
})

vi.mock('@nextcloud/dialogs', () => ({
showError: mocks.showError
}))

vi.mock('../utils/logfile.ts', () => {
return {
parseLogFile: mocks.parseLogFile,
parseLogString: mocks.parseLogString,
parseRawLogEntry: vi.fn((v) => v),
}
})

class ServerError extends Error {

public status = 500
Expand Down Expand Up @@ -162,13 +176,6 @@ describe('store:logging', () => {
})

it('loads entries from file', async () => {
vi.mock('../utils/logfile.ts', () => {
return {
parseLogFile: mocks.parseLogFile,
parseRawLogEntry: vi.fn((v) => v),
}
})

vi.mocked(mocks.parseLogFile).mockImplementation(async () => {
return [{ message: 'hello' }]
})
Expand Down Expand Up @@ -197,13 +204,6 @@ describe('store:logging', () => {
})

it('does not load file if no file was selected', async () => {
vi.mock('../utils/logfile.ts', () => {
return {
parseLogFile: mocks.parseLogFile,
parseRawLogEntry: vi.fn((v) => v),
}
})

vi.mock('../utils/logger.ts', () => {
return {
logger: mocks.logger,
Expand All @@ -227,6 +227,121 @@ describe('store:logging', () => {
expect(mocks.parseLogFile).not.toBeCalled()
})

it('loads entries from clipboard', async () => {
mocks.parseLogString.mockImplementationOnce(() => [{ message: 'hello' }])

// clean pinia
createTestingPinia({
fakeApp: true,
createSpy: vi.fn,
stubActions: false,
})

const clipboard = '{message: "hello"}'
window.navigator.clipboard.readText = vi.fn(() => Promise.resolve(clipboard))

const store = useLogStore()
const settings = useSettingsStore()

store.hasRemainingEntries = true
expect(store.hasRemainingEntries).toBe(true)

await store.loadClipboard()

// File parsed, so there are no remaining entries
expect(store.hasRemainingEntries).toBe(false)
expect(window.navigator.clipboard.readText).toBeCalled()
expect(settings.localFileName).toBe('Clipboard')
expect(mocks.parseLogString).toBeCalledWith(clipboard)
expect(store.allEntries).toEqual([{ message: 'hello' }])
})

it('handles unsupported Clipboard API', async () => {
mocks.parseLogString.mockImplementationOnce(() => [{ message: 'hello' }])

// clean pinia
createTestingPinia({
fakeApp: true,
createSpy: vi.fn,
stubActions: false,
})

const clipboard = '{message: "hello"}'
window.navigator.clipboard.readText = vi.fn(() => Promise.reject(new Error()))
window.prompt = vi.fn(() => clipboard)

const store = useLogStore()
const settings = useSettingsStore()

store.hasRemainingEntries = true
expect(store.hasRemainingEntries).toBe(true)

await store.loadClipboard()

// File parsed, so there are no remaining entries
expect(store.hasRemainingEntries).toBe(false)
expect(window.navigator.clipboard.readText).toBeCalled()
expect(window.prompt).toBeCalled()
expect(settings.localFileName).toBe('Clipboard')
expect(mocks.parseLogString).toBeCalledWith(clipboard)
expect(store.allEntries).toEqual([{ message: 'hello' }])
})

it('handles empty clipboard paste', async () => {
// clean pinia
createTestingPinia({
fakeApp: true,
createSpy: vi.fn,
stubActions: false,
})

window.navigator.clipboard.readText = vi.fn(() => Promise.reject(new Error()))
window.prompt = vi.fn(() => null)

const store = useLogStore()
const settings = useSettingsStore()

store.hasRemainingEntries = true
expect(store.hasRemainingEntries).toBe(true)

await store.loadClipboard()

// File parsed, so there are no remaining entries
expect(store.hasRemainingEntries).toBe(true)
expect(window.navigator.clipboard.readText).toBeCalled()
expect(window.prompt).toBeCalled()
expect(settings.localFile).toBe(undefined)
expect(settings.localFileName).toBe('')
})

it('handles invalid clipboard paste', async () => {
// clean pinia
createTestingPinia({
fakeApp: true,
createSpy: vi.fn,
stubActions: false,
})

window.navigator.clipboard.readText = vi.fn(() => Promise.resolve('invalid'))
// throw an error
mocks.parseLogString.mockImplementationOnce(() => { throw new Error() })

const store = useLogStore()
const settings = useSettingsStore()

store.hasRemainingEntries = true
expect(store.hasRemainingEntries).toBe(true)

await store.loadClipboard()

// File parsed, so there are no remaining entries
expect(store.hasRemainingEntries).toBe(true)
expect(window.navigator.clipboard.readText).toBeCalled()
expect(mocks.showError).toBeCalled()
expect(settings.localFile).toBe(undefined)
expect(settings.localFileName).toBe('')
})

it('loads more from server', async () => {
vi.mock('../api.ts', () => {
return {
Expand Down Expand Up @@ -547,11 +662,6 @@ describe('store:logging', () => {
logger: mocks.logger,
}
})
vi.mock('@nextcloud/dialogs', () => {
return {
showError: mocks.showError,
}
})
vi.mocked(mocks.pollLog).mockImplementationOnce(() => { throw Error() })

// clean pinia
Expand Down Expand Up @@ -581,11 +691,6 @@ describe('store:logging', () => {
logger: mocks.logger,
}
})
vi.mock('@nextcloud/dialogs', () => {
return {
showError: mocks.showError,
}
})
vi.mocked(mocks.pollLog).mockImplementationOnce(() => { throw new ServerError() })

// clean pinia
Expand Down
34 changes: 32 additions & 2 deletions src/store/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { POLLING_INTERVAL } from '../constants'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { useSettingsStore } from './settings'
import { parseLogFile, parseRawLogEntry } from '../utils/logfile'
import { parseLogFile, parseLogString, parseRawLogEntry } from '../utils/logfile'
import { logger } from '../utils/logger'

/**
Expand Down Expand Up @@ -98,6 +98,36 @@ export const useLogStore = defineStore('logreader-logs', () => {
hasRemainingEntries.value = false
}

/**
* Load entries from clipboard
*/
async function loadClipboard() {
// try if the browser supports the async clipboard api, e.g. firefox does not.
let text = ''
try {
text = await window.navigator.clipboard.readText()
} catch (e) {
text = window.prompt(t('logreader', 'Your browser does not support pasting entries directly. Please paste the log entry manually.')) ?? ''
}

// Skip if aborted
if (text === '') {
return
}

try {
allEntries.value = await parseLogString(text)
// TRANSLATORS The clipboard used to paste stuff
_settings.localFile = new File([], t('logreader', 'Clipboard'))
// From clipboard so no more entries
hasRemainingEntries.value = false
} catch (e) {
// TRANSLATORS Error when the pasted content from the clipboard could not be parsed
showError(t('logreader', 'Could not parse clipboard content'))
logger.error(e as Error)
}
}

/**
* Stop polling entries
*/
Expand Down Expand Up @@ -166,5 +196,5 @@ export const useLogStore = defineStore('logreader-logs', () => {
}
}

return { allEntries, entries, hasRemainingEntries, query, loadMore, loadFile, startPolling, stopPolling, searchLogs }
return { allEntries, entries, hasRemainingEntries, query, loadMore, loadClipboard, loadFile, startPolling, stopPolling, searchLogs }
})

0 comments on commit 6edb6a5

Please sign in to comment.