Skip to content

Commit 9e5990c

Browse files
committed
feat: Allow pasting log entries
Signed-off-by: Ferdinand Thiessen <[email protected]>
1 parent 5f9626a commit 9e5990c

File tree

5 files changed

+207
-28
lines changed

5 files changed

+207
-28
lines changed

lib/Constants.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,36 @@
2828

2929
// !! Keep in sync with src/constants.ts
3030
class Constants {
31+
// Used config Keys
32+
3133
/**
32-
* Used AppConfig Keys
34+
* Logging levels to show, used for filtering
3335
*/
3436
public const CONFIG_KEY_SHOWNLEVELS = 'shownLevels';
37+
/**
38+
* Display format of the timestamp
39+
*/
3540
public const CONFIG_KEY_DATETIMEFORMAT = 'dateTimeFormat';
41+
/**
42+
* If relative dates should be shown for the timestamp (e.g. '3 hours ago')
43+
*/
3644
public const CONFIG_KEY_RELATIVEDATES = 'relativedates';
45+
/**
46+
* If automatic updates of the UI are enabled (polling for new entries)
47+
*/
3748
public const CONFIG_KEY_LIVELOG = 'liveLog';
49+
50+
/**
51+
* All valid config keys
52+
*/
3853
public const CONFIG_KEYS = [
3954
self::CONFIG_KEY_SHOWNLEVELS,
4055
self::CONFIG_KEY_DATETIMEFORMAT,
4156
self::CONFIG_KEY_RELATIVEDATES,
42-
self::CONFIG_KEY_LIVELOG
57+
self::CONFIG_KEY_LIVELOG,
4358
];
4459

60+
// other constants
4561
public const LOGGING_LEVELS = [0, 1, 2, 3, 4];
4662
public const LOGGING_LEVEL_NAMES = [
4763
'debug',

src/App.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ const onShowServerLog = () => {
8181
loggingStore.loadMore()
8282
}
8383
84+
/**
85+
* Handle pressing ctrl + v to paste log entries
86+
* @param event The keyboard event
87+
*/
88+
const onHandlePaste = (event: KeyboardEvent) => {
89+
// Check Ctrl + v (be tolerant: ignore caps lock) and only intercept if target is no input for pasting
90+
if ((event.key === 'v' || event.key === 'V') && event.ctrlKey && (event.target as HTMLElement)?.tagName !== 'INPUT') {
91+
loggingStore.loadClipboard()
92+
event.stopPropagation()
93+
}
94+
}
95+
// Add / remove event listeners
96+
onMounted(() => window.addEventListener('keyup', onHandlePaste))
97+
onUnmounted(() => window.removeEventListener('keyup', onHandlePaste))
98+
8499
/**
85100
* Toggle polling if live log is dis- / enabled
86101
*/

src/components/settings/SettingsActions.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
<template>
22
<div>
3+
<NcNoteCard type="info" class="info-note">
4+
<!-- eslint-disable-next-line vue/no-v-html -->
5+
<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 })" />
6+
</NcNoteCard>
37
<NcButton :href="settingsStore.enabled ? downloadURL : null" :disabled="!settingsStore.enabled" download="nextcloud.log">
48
<template #icon>
59
<IconDownload :size="20" />
@@ -31,12 +35,16 @@ import { useLogStore } from '../../store/logging'
3135
import { useSettingsStore } from '../../store/settings.js'
3236
3337
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
38+
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
3439
import IconDownload from 'vue-material-design-icons/Download.vue'
3540
import IconUpload from 'vue-material-design-icons/Upload.vue'
3641
3742
const settingsStore = useSettingsStore()
3843
const logStore = useLogStore()
3944
45+
// TRANSLATORS The control key abbreviation
46+
const keyboardShortcutText = `<kbd>${t('logreader', 'Ctrl')}</kbd> + <kbd>v</kbd>`
47+
4048
/**
4149
* Logfile download URL
4250
*/
@@ -66,6 +74,11 @@ const onFileSelected = () => {
6674
<style lang="scss" scoped>
6775
div {
6876
display: flex;
77+
flex-wrap: wrap;
6978
gap: 12px;
79+
padding-inline-end: 12px;
80+
}
81+
.info-note {
82+
justify-self: stretch;
7083
}
7184
</style>

src/store/logging.spec.ts

Lines changed: 129 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,30 @@ import { POLLING_INTERVAL } from '../constants'
1414
const mocks = vi.hoisted(() => {
1515
return {
1616
parseLogFile: vi.fn(),
17+
parseLogString: vi.fn(),
1718
logger: {
1819
debug: vi.fn(),
1920
warn: vi.fn(),
21+
error: vi.fn(),
2022
},
2123
getLog: vi.fn(),
2224
pollLog: vi.fn(),
2325
showError: vi.fn(),
2426
}
2527
})
2628

29+
vi.mock('@nextcloud/dialogs', () => ({
30+
showError: mocks.showError
31+
}))
32+
33+
vi.mock('../utils/logfile.ts', () => {
34+
return {
35+
parseLogFile: mocks.parseLogFile,
36+
parseLogString: mocks.parseLogString,
37+
parseRawLogEntry: vi.fn((v) => v),
38+
}
39+
})
40+
2741
class ServerError extends Error {
2842

2943
public status = 500
@@ -162,13 +176,6 @@ describe('store:logging', () => {
162176
})
163177

164178
it('loads entries from file', async () => {
165-
vi.mock('../utils/logfile.ts', () => {
166-
return {
167-
parseLogFile: mocks.parseLogFile,
168-
parseRawLogEntry: vi.fn((v) => v),
169-
}
170-
})
171-
172179
vi.mocked(mocks.parseLogFile).mockImplementation(async () => {
173180
return [{ message: 'hello' }]
174181
})
@@ -197,13 +204,6 @@ describe('store:logging', () => {
197204
})
198205

199206
it('does not load file if no file was selected', async () => {
200-
vi.mock('../utils/logfile.ts', () => {
201-
return {
202-
parseLogFile: mocks.parseLogFile,
203-
parseRawLogEntry: vi.fn((v) => v),
204-
}
205-
})
206-
207207
vi.mock('../utils/logger.ts', () => {
208208
return {
209209
logger: mocks.logger,
@@ -227,6 +227,121 @@ describe('store:logging', () => {
227227
expect(mocks.parseLogFile).not.toBeCalled()
228228
})
229229

230+
it('loads entries from clipboard', async () => {
231+
mocks.parseLogString.mockImplementationOnce(() => [{ message: 'hello' }])
232+
233+
// clean pinia
234+
createTestingPinia({
235+
fakeApp: true,
236+
createSpy: vi.fn,
237+
stubActions: false,
238+
})
239+
240+
const clipboard = '{message: "hello"}'
241+
window.navigator.clipboard.readText = vi.fn(() => Promise.resolve(clipboard))
242+
243+
const store = useLogStore()
244+
const settings = useSettingsStore()
245+
246+
store.hasRemainingEntries = true
247+
expect(store.hasRemainingEntries).toBe(true)
248+
249+
await store.loadClipboard()
250+
251+
// File parsed, so there are no remaining entries
252+
expect(store.hasRemainingEntries).toBe(false)
253+
expect(window.navigator.clipboard.readText).toBeCalled()
254+
expect(settings.localFileName).toBe('Clipboard')
255+
expect(mocks.parseLogString).toBeCalledWith(clipboard)
256+
expect(store.allEntries).toEqual([{ message: 'hello' }])
257+
})
258+
259+
it('handles unsupported Clipboard API', async () => {
260+
mocks.parseLogString.mockImplementationOnce(() => [{ message: 'hello' }])
261+
262+
// clean pinia
263+
createTestingPinia({
264+
fakeApp: true,
265+
createSpy: vi.fn,
266+
stubActions: false,
267+
})
268+
269+
const clipboard = '{message: "hello"}'
270+
window.navigator.clipboard.readText = vi.fn(() => Promise.reject(new Error()))
271+
window.prompt = vi.fn(() => clipboard)
272+
273+
const store = useLogStore()
274+
const settings = useSettingsStore()
275+
276+
store.hasRemainingEntries = true
277+
expect(store.hasRemainingEntries).toBe(true)
278+
279+
await store.loadClipboard()
280+
281+
// File parsed, so there are no remaining entries
282+
expect(store.hasRemainingEntries).toBe(false)
283+
expect(window.navigator.clipboard.readText).toBeCalled()
284+
expect(window.prompt).toBeCalled()
285+
expect(settings.localFileName).toBe('Clipboard')
286+
expect(mocks.parseLogString).toBeCalledWith(clipboard)
287+
expect(store.allEntries).toEqual([{ message: 'hello' }])
288+
})
289+
290+
it('handles empty clipboard paste', async () => {
291+
// clean pinia
292+
createTestingPinia({
293+
fakeApp: true,
294+
createSpy: vi.fn,
295+
stubActions: false,
296+
})
297+
298+
window.navigator.clipboard.readText = vi.fn(() => Promise.reject(new Error()))
299+
window.prompt = vi.fn(() => null)
300+
301+
const store = useLogStore()
302+
const settings = useSettingsStore()
303+
304+
store.hasRemainingEntries = true
305+
expect(store.hasRemainingEntries).toBe(true)
306+
307+
await store.loadClipboard()
308+
309+
// File parsed, so there are no remaining entries
310+
expect(store.hasRemainingEntries).toBe(true)
311+
expect(window.navigator.clipboard.readText).toBeCalled()
312+
expect(window.prompt).toBeCalled()
313+
expect(settings.localFile).toBe(undefined)
314+
expect(settings.localFileName).toBe('')
315+
})
316+
317+
it('handles invalid clipboard paste', async () => {
318+
// clean pinia
319+
createTestingPinia({
320+
fakeApp: true,
321+
createSpy: vi.fn,
322+
stubActions: false,
323+
})
324+
325+
window.navigator.clipboard.readText = vi.fn(() => Promise.resolve('invalid'))
326+
// throw an error
327+
mocks.parseLogString.mockImplementationOnce(() => { throw new Error() })
328+
329+
const store = useLogStore()
330+
const settings = useSettingsStore()
331+
332+
store.hasRemainingEntries = true
333+
expect(store.hasRemainingEntries).toBe(true)
334+
335+
await store.loadClipboard()
336+
337+
// File parsed, so there are no remaining entries
338+
expect(store.hasRemainingEntries).toBe(true)
339+
expect(window.navigator.clipboard.readText).toBeCalled()
340+
expect(mocks.showError).toBeCalled()
341+
expect(settings.localFile).toBe(undefined)
342+
expect(settings.localFileName).toBe('')
343+
})
344+
230345
it('loads more from server', async () => {
231346
vi.mock('../api.ts', () => {
232347
return {
@@ -547,11 +662,6 @@ describe('store:logging', () => {
547662
logger: mocks.logger,
548663
}
549664
})
550-
vi.mock('@nextcloud/dialogs', () => {
551-
return {
552-
showError: mocks.showError,
553-
}
554-
})
555665
vi.mocked(mocks.pollLog).mockImplementationOnce(() => { throw Error() })
556666

557667
// clean pinia
@@ -581,11 +691,6 @@ describe('store:logging', () => {
581691
logger: mocks.logger,
582692
}
583693
})
584-
vi.mock('@nextcloud/dialogs', () => {
585-
return {
586-
showError: mocks.showError,
587-
}
588-
})
589694
vi.mocked(mocks.pollLog).mockImplementationOnce(() => { throw new ServerError() })
590695

591696
// clean pinia

src/store/logging.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { POLLING_INTERVAL } from '../constants'
1313
import { showError } from '@nextcloud/dialogs'
1414
import { translate as t } from '@nextcloud/l10n'
1515
import { useSettingsStore } from './settings'
16-
import { parseLogFile, parseRawLogEntry } from '../utils/logfile'
16+
import { parseLogFile, parseLogString, parseRawLogEntry } from '../utils/logfile'
1717
import { logger } from '../utils/logger'
1818

1919
/**
@@ -98,6 +98,36 @@ export const useLogStore = defineStore('logreader-logs', () => {
9898
hasRemainingEntries.value = false
9999
}
100100

101+
/**
102+
* Load entries from clipboard
103+
*/
104+
async function loadClipboard() {
105+
// try if the browser supports the async clipboard api, e.g. firefox does not.
106+
let text = ''
107+
try {
108+
text = await window.navigator.clipboard.readText()
109+
} catch (e) {
110+
text = window.prompt(t('logreader', 'Your browser does not support pasting entries directly. Please paste the log entry manually.')) ?? ''
111+
}
112+
113+
// Skip if aborted
114+
if (text === '') {
115+
return
116+
}
117+
118+
try {
119+
allEntries.value = await parseLogString(text)
120+
// TRANSLATORS The clipboard used to paste stuff
121+
_settings.localFile = new File([], t('logreader', 'Clipboard'))
122+
// From clipboard so no more entries
123+
hasRemainingEntries.value = false
124+
} catch (e) {
125+
// TRANSLATORS Error when the pasted content from the clipboard could not be parsed
126+
showError(t('logreader', 'Could not parse clipboard content'))
127+
logger.error(e as Error)
128+
}
129+
}
130+
101131
/**
102132
* Stop polling entries
103133
*/
@@ -166,5 +196,5 @@ export const useLogStore = defineStore('logreader-logs', () => {
166196
}
167197
}
168198

169-
return { allEntries, entries, hasRemainingEntries, query, loadMore, loadFile, startPolling, stopPolling, searchLogs }
199+
return { allEntries, entries, hasRemainingEntries, query, loadMore, loadClipboard, loadFile, startPolling, stopPolling, searchLogs }
170200
})

0 commit comments

Comments
 (0)