From c2e5c16c29176d2a550b4630dd5a475c87d3fc77 Mon Sep 17 00:00:00 2001 From: Lajos Date: Sun, 2 Jun 2024 12:02:59 +0200 Subject: [PATCH] Feat/text file editing (#57) * feat: Text file editing * improve schema-generation performance * regenerated schemas * fix schema validation * cors fix * wired api and added feedback * test fix * add upload test * upload test fix * e2e fix * try fix tests * lockfile fix * fix sdk version * fix message type * test and list update fixes * exit code on schema generation failure * reverted schema generation exit code * schema generation to prev. --- .vscode/settings.json | 7 +- common/schemas/drives-api.json | 76 +++++-- common/schemas/media-entities.json | 189 +++--------------- common/src/apis/drives.ts | 16 ++ common/src/bin/create-schemas.ts | 34 ++-- e2e/file-browser.spec.ts | 18 +- e2e/helpers.ts | 24 +++ e2e/test-files/upload.md | 1 + frontend/src/pages/file-browser/file-list.tsx | 1 + .../src/pages/file-browser/folder-panel.tsx | 4 + .../src/pages/files/monaco-file-editor.tsx | 99 +++++++-- frontend/src/services/drives-service.ts | 3 +- .../drives/actions/save-text-file-action.ts | 33 +++ service/src/drives/actions/upload-action.ts | 2 +- service/src/drives/setup-drives-rest-api.ts | 6 + service/src/drives/setup-drives.ts | 10 +- service/src/get-cors-options.spec.ts | 2 +- service/src/get-cors-options.ts | 2 +- yarn.lock | 26 ++- 19 files changed, 328 insertions(+), 225 deletions(-) create mode 100644 e2e/test-files/upload.md create mode 100644 service/src/drives/actions/save-text-file-action.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a38f7ae..867faa32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,7 @@ { - "typescript.tsdk": ".yarn/sdks/typescript/lib", + "typescript.tsdk": "node_modules\\typescript\\lib", "editor.formatOnSave": true, - "eslint.validate": [ - "typescript", - "typescriptreact" - ], + "eslint.validate": ["typescript", "typescriptreact"], "search.exclude": { "**/.yarn": true, "**/.pnp.*": true diff --git a/common/schemas/drives-api.json b/common/schemas/drives-api.json index 5b6ee313..3911f90a 100644 --- a/common/schemas/drives-api.json +++ b/common/schemas/drives-api.json @@ -1303,6 +1303,58 @@ ], "additionalProperties": false }, + "SaveTextFileEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "letter": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "letter", + "path" + ], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + } + }, + "required": [ + "success" + ], + "additionalProperties": false + } + }, + "required": [ + "url", + "body", + "result" + ], + "additionalProperties": false + }, "DrivesApi": { "type": "object", "properties": { @@ -1364,20 +1416,15 @@ }, "PUT": { "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "result": {}, - "url": {}, - "query": {}, - "body": {}, - "headers": {} - }, - "required": [ - "result" - ], - "additionalProperties": false - } + "properties": { + "/files/:letter/:path": { + "$ref": "#/definitions/SaveTextFileEndpoint" + } + }, + "required": [ + "/files/:letter/:path" + ], + "additionalProperties": false }, "DELETE": { "type": "object", @@ -1467,6 +1514,7 @@ "required": [ "GET", "POST", + "PUT", "PATCH", "DELETE" ], diff --git a/common/schemas/media-entities.json b/common/schemas/media-entities.json index db565c6a..36240884 100644 --- a/common/schemas/media-entities.json +++ b/common/schemas/media-entities.json @@ -26,14 +26,7 @@ "type": "string" } }, - "required": [ - "imdbId", - "title", - "year", - "plot", - "createdAt", - "updatedAt" - ], + "required": ["imdbId", "title", "year", "plot", "createdAt", "updatedAt"], "additionalProperties": false }, "OmdbSeriesMetadata": { @@ -93,10 +86,7 @@ "type": "string" } }, - "required": [ - "Source", - "Value" - ], + "required": ["Source", "Value"], "additionalProperties": false } }, @@ -213,10 +203,7 @@ "type": "string" } }, - "required": [ - "Source", - "Value" - ], + "required": ["Source", "Value"], "additionalProperties": false } }, @@ -234,10 +221,7 @@ }, "Type": { "type": "string", - "enum": [ - "movie", - "episode" - ] + "enum": ["movie", "episode"] }, "DVD": { "type": "string" @@ -307,9 +291,7 @@ "properties": { "metadata": {} }, - "required": [ - "metadata" - ], + "required": ["metadata"], "additionalProperties": false } } @@ -343,10 +325,7 @@ }, "type": { "type": "string", - "enum": [ - "movie", - "episode" - ] + "enum": ["movie", "episode"] }, "seriesId": { "type": "string" @@ -364,12 +343,7 @@ "type": "string" } }, - "required": [ - "imdbId", - "title", - "createdAt", - "updatedAt" - ], + "required": ["imdbId", "title", "createdAt", "updatedAt"], "additionalProperties": false }, "MovieWatchHistoryEntry": { @@ -448,34 +422,18 @@ "properties": { "type": { "type": "string", - "enum": [ - "subtitle", - "audio", - "trailer", - "info", - "other" - ] + "enum": ["subtitle", "audio", "trailer", "info", "other"] }, "path": { "type": "string" } }, - "required": [ - "type", - "path" - ], + "required": ["type", "path"], "additionalProperties": false } } }, - "required": [ - "id", - "imdbId", - "driveLetter", - "path", - "fileName", - "ffprobe" - ], + "required": ["id", "imdbId", "driveLetter", "path", "fileName", "ffprobe"], "additionalProperties": false }, "getInfo.FFProbeResult": { @@ -488,9 +446,7 @@ } } }, - "required": [ - "streams" - ], + "required": ["streams"], "additionalProperties": false }, "getInfo.FFProbeStream": { @@ -510,11 +466,7 @@ }, "codec_type": { "type": "string", - "enum": [ - "video", - "audio", - "images" - ] + "enum": ["video", "audio", "images"] }, "codec_time_base": { "type": "string" @@ -748,10 +700,7 @@ }, "getInfo.FFProbeBoolean": { "type": "string", - "enum": [ - "0", - "1" - ] + "enum": ["0", "1"] }, "getInfo.SideData": { "anyOf": [ @@ -805,9 +754,7 @@ "description": "Based on the C code related to the default side data section {@see https://github.com/FFmpeg/FFmpeg/blob/b37795688a9bfa6d5a2e9b2535c4b10ebc14ac5d/fftools/ffprobe.c#L2298}" } }, - "required": [ - "side_data_type" - ], + "required": ["side_data_type"], "additionalProperties": false }, "getInfo.BaseSideData": { @@ -817,9 +764,7 @@ "type": "string" } }, - "required": [ - "side_data_type" - ], + "required": ["side_data_type"], "additionalProperties": false }, "getInfo.DisplayMatrixSideData": { @@ -838,11 +783,7 @@ "type": "number" } }, - "required": [ - "displaymatrix", - "rotation", - "side_data_type" - ], + "required": ["displaymatrix", "rotation", "side_data_type"], "additionalProperties": false }, "getInfo.Stereo3dSideData": { @@ -872,11 +813,7 @@ "type": "number" } }, - "required": [ - "inverted", - "side_data_type", - "type" - ], + "required": ["inverted", "side_data_type", "type"], "additionalProperties": false }, "getInfo.SphericalMappingSideData": { @@ -918,13 +855,7 @@ "type": "number" } }, - "required": [ - "pitch", - "projection", - "roll", - "side_data_type", - "yaw" - ], + "required": ["pitch", "projection", "roll", "side_data_type", "yaw"], "additionalProperties": false }, "getInfo.BaseSphericalMappingSideData": { @@ -949,13 +880,7 @@ "type": "number" } }, - "required": [ - "pitch", - "projection", - "roll", - "side_data_type", - "yaw" - ], + "required": ["pitch", "projection", "roll", "side_data_type", "yaw"], "additionalProperties": false }, "getInfo.EquirectangularSphericalMappingSideData": { @@ -981,13 +906,7 @@ "type": "number" } }, - "required": [ - "pitch", - "projection", - "roll", - "side_data_type", - "yaw" - ], + "required": ["pitch", "projection", "roll", "side_data_type", "yaw"], "additionalProperties": false }, "getInfo.CubeMapSphericalMappingSideData": { @@ -1017,14 +936,7 @@ "description": "Based on the C code related to Cube Map Spherical Mapping side data section {@see https://github.com/FFmpeg/FFmpeg/blob/b37795688a9bfa6d5a2e9b2535c4b10ebc14ac5d/fftools/ffprobe.c#L2313}" } }, - "required": [ - "padding", - "pitch", - "projection", - "roll", - "side_data_type", - "yaw" - ], + "required": ["padding", "pitch", "projection", "roll", "side_data_type", "yaw"], "additionalProperties": false }, "getInfo.TiltedEquirectangularSphericalMappingSideData": { @@ -1097,13 +1009,7 @@ "type": "number" } }, - "required": [ - "discard_padding", - "discard_reason", - "side_data_type", - "skip_reason", - "skip_samples" - ], + "required": ["discard_padding", "discard_reason", "side_data_type", "skip_reason", "skip_samples"], "additionalProperties": false }, "getInfo.MasteringDisplayMetadataSideData": { @@ -1228,9 +1134,7 @@ "description": "Based on the C code related to Mastering display metadata side data section {@see https://github.com/FFmpeg/FFmpeg/blob/b37795688a9bfa6d5a2e9b2535c4b10ebc14ac5d/fftools/ffprobe.c#L2333-L2350}" } }, - "required": [ - "side_data_type" - ], + "required": ["side_data_type"], "additionalProperties": false }, "getInfo.LuminanceMasteringDisplayMetadataSideData": { @@ -1249,11 +1153,7 @@ "type": "string" } }, - "required": [ - "max_luminance", - "min_luminance", - "side_data_type" - ], + "required": ["max_luminance", "min_luminance", "side_data_type"], "additionalProperties": false }, "getInfo.ContentLightLevelMetadataSideData": { @@ -1271,11 +1171,7 @@ "type": "number" } }, - "required": [ - "max_average", - "max_content", - "side_data_type" - ], + "required": ["max_average", "max_content", "side_data_type"], "additionalProperties": false }, "getInfo.DoviConfigurationRecordSideData": { @@ -1336,10 +1232,7 @@ "type": "number" } }, - "required": [ - "service_type", - "side_data_type" - ], + "required": ["service_type", "side_data_type"], "additionalProperties": false }, "getInfo.MpegtsStreamIdSideData": { @@ -1354,10 +1247,7 @@ "type": "number" } }, - "required": [ - "id", - "side_data_type" - ], + "required": ["id", "side_data_type"], "additionalProperties": false }, "getInfo.CpbPropertiesSideData": { @@ -1384,14 +1274,7 @@ "type": "number" } }, - "required": [ - "avg_bitrate", - "buffer_size", - "max_bitrate", - "min_bitrate", - "side_data_type", - "vbv_delay" - ], + "required": ["avg_bitrate", "buffer_size", "max_bitrate", "min_bitrate", "side_data_type", "vbv_delay"], "additionalProperties": false }, "getInfo.WebvttSideData": { @@ -1399,10 +1282,7 @@ "properties": { "side_data_type": { "type": "string", - "enum": [ - "WebVTT ID", - "WebVTT Settings" - ], + "enum": ["WebVTT ID", "WebVTT Settings"], "description": "Based on the C code related to Webvtt side data section {@see https://github.com/FFmpeg/FFmpeg/blob/b37795688a9bfa6d5a2e9b2535c4b10ebc14ac5d/fftools/ffprobe.c#L2379-L2381}" }, "data": { @@ -1412,10 +1292,7 @@ "type": "string" } }, - "required": [ - "data_hash", - "side_data_type" - ], + "required": ["data_hash", "side_data_type"], "additionalProperties": false }, "getInfo.ActiveFormatDescriptionSideData": { @@ -1430,10 +1307,8 @@ "type": "number" } }, - "required": [ - "side_data_type" - ], + "required": ["side_data_type"], "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/common/src/apis/drives.ts b/common/src/apis/drives.ts index 7ff79614..20ac77af 100644 --- a/common/src/apis/drives.ts +++ b/common/src/apis/drives.ts @@ -65,6 +65,19 @@ export type FfprobeEndpoint = { result: FFProbeResult } +export type SaveTextFileEndpoint = { + url: { + letter: string + path: string + } + body: { + text: string + } + result: { + success: true + } +} + export interface DrivesApi extends RestApi { GET: { '/volumes': GetCollectionEndpoint @@ -77,6 +90,9 @@ export interface DrivesApi extends RestApi { '/volumes': PostDriveEndpoint '/volumes/:letter/:path/upload': UploadEndpoint } + PUT: { + '/files/:letter/:path': SaveTextFileEndpoint + } PATCH: { '/volumes/:id': PatchEndpoint } diff --git a/common/src/bin/create-schemas.ts b/common/src/bin/create-schemas.ts index 3dba7cf9..96436d4b 100644 --- a/common/src/bin/create-schemas.ts +++ b/common/src/bin/create-schemas.ts @@ -93,21 +93,25 @@ export const apiValues: SchemaGenerationSetting[] = [ ] export const exec = async (): Promise => { - for (const schemaValue of [...entityValues, ...apiValues]) { - try { - console.log(`Create schema from ${schemaValue.inputFile} to ${schemaValue.outputFile}`) - const schema = createGenerator({ - path: join(process.cwd(), schemaValue.inputFile), - tsconfig: join(process.cwd(), './tsconfig.json'), - skipTypeCheck: true, - expose: 'all', - }).createSchema(schemaValue.type) - await promises.writeFile(join(process.cwd(), schemaValue.outputFile), JSON.stringify(schema, null, 2)) - } catch (error) { - console.error(`There was an error generating schema from ${schemaValue.inputFile}`, error) - process.exit(1) - } - } + await Promise.all( + [...entityValues, ...apiValues].map(async (schemaValue) => { + try { + const inputFile = join(process.cwd(), schemaValue.inputFile) + const outputFile = join(process.cwd(), schemaValue.outputFile) + + console.log(`Create schema from ${inputFile} to ${outputFile}`) + const schema = createGenerator({ + path: inputFile, + tsconfig: join(process.cwd(), './tsconfig.json'), + skipTypeCheck: true, + expose: 'all', + }).createSchema(schemaValue.type) + await promises.writeFile(outputFile, JSON.stringify(schema, null, 2)) + } catch (error) { + console.error(`There was an error generating schema from ${schemaValue.inputFile}`, error) + } + }), + ) } exec() diff --git a/e2e/file-browser.spec.ts b/e2e/file-browser.spec.ts index 9072085b..335bd0dd 100644 --- a/e2e/file-browser.spec.ts +++ b/e2e/file-browser.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test' -import { assertAndDismissNoty, login } from './helpers' +import { assertAndDismissNoty, login, uploadFile } from './helpers' import { join } from 'path' +import { readFile } from 'fs/promises' test.describe('File Browser', () => { test('Should be able to create a drive in the temp directory', async ({ page, browserName }) => { @@ -29,6 +30,19 @@ test.describe('File Browser', () => { await assertAndDismissNoty(page, `Drive '${browserName[0]}' has been created succesfully`) await page.getByText('- No Data -') - // TODO: Upload files + }) + + test('Should able to upload a file', async ({ page }) => { + await page.goto('/') + await login(page) + await page.locator('icon-url-widget', { hasText: 'File Browser' }).click() + await uploadFile(page, './e2e/test-files/upload.md', 'text/markdown') + await page.getByText('upload.md').nth(1).dblclick() + + const fileContent = await readFile('./e2e/test-files/upload.md', { encoding: 'utf-8' }) + const editor = await page.locator('monaco-editor').getByRole('textbox') + await expect(fileContent.replace(/[^a-zA-Z ]/g, '')).toContain( + (await editor.inputValue()).replace(/[^a-zA-Z ]/g, ''), + ) }) }) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 446d0ee5..4a1ea471 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,5 +1,7 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' +import { basename } from 'path' +import { readFile } from 'fs/promises' export const assertAndDismissNoty = async (page: Page, text: string) => { const noty = await page.locator('shade-noty', { hasText: text }) @@ -43,3 +45,25 @@ export const logout = async (page: Page) => { const loggedOutLoginForm = await page.locator('shade-login form.login-form') await expect(loggedOutLoginForm).toBeVisible() } + +export const uploadFile = async (page: Page, filePath: string, mime: string) => { + const fileContent = await readFile(filePath, { encoding: 'utf-8' }) + const fileName = basename(filePath) + + const dataTransfer = await page.evaluateHandle( + async ([fileNameToUpload, type, content]) => { + const dt = new DataTransfer() + const file = new File([content], fileNameToUpload, { type }) + dt.items.add(file) + return dt + }, + [fileName, mime, fileContent], + ) + + const fileDrop = await page.getByTestId('file-drop').first() + await fileDrop.dispatchEvent('drop', { dataTransfer }) + + await assertAndDismissNoty(page, `The files are upploaded succesfully`) + + await dataTransfer.dispose() +} diff --git a/e2e/test-files/upload.md b/e2e/test-files/upload.md new file mode 100644 index 00000000..49bb5eb9 --- /dev/null +++ b/e2e/test-files/upload.md @@ -0,0 +1 @@ +### Uploaded File diff --git a/frontend/src/pages/file-browser/file-list.tsx b/frontend/src/pages/file-browser/file-list.tsx index 9e2dacbc..154bf963 100644 --- a/frontend/src/pages/file-browser/file-list.tsx +++ b/frontend/src/pages/file-browser/file-list.tsx @@ -82,6 +82,7 @@ export const FileList = Shade<{ return (
{ ev.preventDefault() }} diff --git a/frontend/src/pages/file-browser/folder-panel.tsx b/frontend/src/pages/file-browser/folder-panel.tsx index d787801e..d3882ecb 100644 --- a/frontend/src/pages/file-browser/folder-panel.tsx +++ b/frontend/src/pages/file-browser/folder-panel.tsx @@ -67,6 +67,10 @@ export const FolderPanel = Shade<{ }) onFileListChange(fileList) + useDisposable('onFilesystemChanged', () => + drivesService.subscribe('onFilesystemChanged', () => drivesService.getFileList(letter, path)), + ) + service.hasFocus.setValue(!!props.focused) element.style.height = '100%' diff --git a/frontend/src/pages/files/monaco-file-editor.tsx b/frontend/src/pages/files/monaco-file-editor.tsx index ebb6b100..b8acba47 100644 --- a/frontend/src/pages/files/monaco-file-editor.tsx +++ b/frontend/src/pages/files/monaco-file-editor.tsx @@ -2,6 +2,9 @@ import { createComponent, Shade } from '@furystack/shades' import { MonacoEditor } from '../../components/monaco-editor.js' import { environmentOptions } from '../../environment-options.js' import { PiRatLazyLoad } from '../../components/pirat-lazy-load.js' +import { ObservableValue } from '@furystack/utils' +import { DrivesApiClient } from '../../services/api-clients/drives-api-client.js' +import { Button, NotyService } from '@furystack/shades-common-components' const getMonacoLanguage = (path: string) => { const extension = path.split('.').pop() @@ -22,7 +25,7 @@ const getMonacoLanguage = (path: string) => { export const MonacoFileEditor = Shade<{ letter: string; path: string }>({ shadowDomName: 'drives-files-monaco-editor', - render: ({ props }) => { + render: ({ props, injector }) => { const { letter, path } = props return ( @@ -42,25 +45,89 @@ export const MonacoFileEditor = Shade<{ letter: string; path: string }>({ } const text = await result.text() + return ( -
- -
+ { + const client = injector.getInstance(DrivesApiClient) + client + .call({ + method: 'PUT', + action: '/files/:letter/:path', + url: { letter: encodeURIComponent(letter), path: encodeURIComponent(path) }, + body: { text: newValue }, + }) + .then(() => { + injector.getInstance(NotyService).emit('onNotyAdded', { + title: 'File saved', + body: `File ${path} has been saved successfully.`, + type: 'success', + }) + }) + .catch((error) => { + injector.getInstance(NotyService).emit('onNotyAdded', { + title: 'Failed to save file', + body: `Failed to save file ${path}: ${error.message}`, + type: 'error', + }) + }) + }} + /> ) }} /> ) }, }) + +const MonacoTextFileEditor = Shade<{ initialValue: string; language: string; onSave: (newValue: string) => void }>({ + shadowDomName: 'monaco-text-file-editor', + render: ({ props, useDisposable }) => { + const { initialValue, language } = props + + const value = useDisposable('value', () => new ObservableValue(initialValue)) + + useDisposable('save', () => { + const onSave = (ev: KeyboardEvent) => { + if (ev.ctrlKey && ev.key === 's') { + ev.preventDefault() + props.onSave(value.getValue()) + } + } + window.addEventListener('keydown', onSave) + return { + dispose: () => window.removeEventListener('keydown', onSave), + } + }) + + return ( +
+ value.setValue(newValue)} + /> +
+
+ + +
+
+ ) + }, +}) diff --git a/frontend/src/services/drives-service.ts b/frontend/src/services/drives-service.ts index 954c6f2b..ee402af5 100644 --- a/frontend/src/services/drives-service.ts +++ b/frontend/src/services/drives-service.ts @@ -129,7 +129,6 @@ export class DrivesService extends EventHub<{ onFilesystemChanged: DrivesFilesys action: '/files/:letter/:path', url: { letter, path }, }) - this.fileListCache.flushAll() return removeResult } @@ -137,7 +136,7 @@ export class DrivesService extends EventHub<{ onFilesystemChanged: DrivesFilesys private declare readonly socket: WebsocketNotificationsService private onMessage = ((messageData: any) => { - if ((messageData as any).type === 'fileChange') { + if ((messageData as any).type === 'file-change') { this.emit('onFilesystemChanged', messageData as DrivesFilesystemChangedEvent) this.fileListCache.obsoleteRange((fileList) => { diff --git a/service/src/drives/actions/save-text-file-action.ts b/service/src/drives/actions/save-text-file-action.ts new file mode 100644 index 00000000..e894ba54 --- /dev/null +++ b/service/src/drives/actions/save-text-file-action.ts @@ -0,0 +1,33 @@ +import { getDataSetFor } from '@furystack/repository' +import { RequestError } from '@furystack/rest' +import type { RequestAction } from '@furystack/rest-service' +import { JsonResult } from '@furystack/rest-service' +import { PathHelper } from '@furystack/utils' +import type { SaveTextFileEndpoint } from 'common' +import { Drive } from 'common' +import { join } from 'path' +import { writeFile } from 'fs/promises' +import { existsAsync } from '../../utils/exists-async.js' + +export const SaveTextFileAction: RequestAction = async ({ getUrlParams, getBody, injector }) => { + const { letter, path } = getUrlParams() + + const dataSet = getDataSetFor(injector, Drive, 'letter') + const drive = await dataSet.get(injector, letter) + + if (!drive) { + throw new RequestError(`Drive ${letter} not found`, 404) + } + + const targetPath = join(drive.physicalPath, PathHelper.getParentPath(path)) + const targetPathExists = await existsAsync(targetPath) + if (!targetPathExists) { + throw new RequestError(`Target path ${targetPath} does not exists`, 400) + } + + const { text } = await getBody() + + await writeFile(join(drive.physicalPath, path), text) + + return JsonResult({ success: true }) +} diff --git a/service/src/drives/actions/upload-action.ts b/service/src/drives/actions/upload-action.ts index 84209fd7..4fffb0b7 100644 --- a/service/src/drives/actions/upload-action.ts +++ b/service/src/drives/actions/upload-action.ts @@ -7,11 +7,11 @@ import { IncomingForm } from 'formidable' import type { UploadEndpoint } from 'common' import { Drive } from 'common' import { getDataSetFor } from '@furystack/repository' -import { existsAsync } from '../setup-drives.js' import type { DirectoryEntry } from 'common' import { join } from 'path' import { createDirentListFromFiles } from '../create-dirent-list-from-files.js' import { isAuthorized } from '@furystack/core' +import { existsAsync } from '../../utils/exists-async.js' export const UploadAction: RequestAction = async ({ injector, getUrlParams, request }) => { if (!isAuthorized(injector, 'admin')) { diff --git a/service/src/drives/setup-drives-rest-api.ts b/service/src/drives/setup-drives-rest-api.ts index 15644a9e..68cdcbe8 100644 --- a/service/src/drives/setup-drives-rest-api.ts +++ b/service/src/drives/setup-drives-rest-api.ts @@ -19,6 +19,7 @@ import { UploadAction } from './actions/upload-action.js' import { DeleteFileAction } from './actions/delete-file-action.js' import { DownloadAction } from './actions/download-action.js' import { FfprobeAction } from './actions/ffprobe-action.js' +import { SaveTextFileAction } from './actions/save-text-file-action.js' export const setupDrivesRestApi = async (injector: Injector) => { await useRestService({ @@ -71,6 +72,11 @@ export const setupDrivesRestApi = async (injector: Injector) => { }), ), }, + PUT: { + '/files/:letter/:path': Validate({ schema: drivesApiSchema, schemaName: 'SaveTextFileEndpoint' })( + SaveTextFileAction, + ), + }, DELETE: { '/volumes/:id': createDeleteEndpoint({ model: Drive, diff --git a/service/src/drives/setup-drives.ts b/service/src/drives/setup-drives.ts index 6f9bb6ab..722f37a9 100644 --- a/service/src/drives/setup-drives.ts +++ b/service/src/drives/setup-drives.ts @@ -9,15 +9,7 @@ import { Model, DataTypes } from 'sequelize' import { getDefaultDbSettings } from '../get-default-db-options.js' import { useFileWatchers } from './file-watcher-service.js' import { withRole } from '../authorization/with-role.js' - -export const existsAsync = async (path: string, mode?: number) => { - try { - await access(path, mode) - } catch { - return false - } - return true -} +import { existsAsync } from '../utils/exists-async.js' const ensureFolder = async (path: string, mode: number = constants.W_OK) => { const exists = await existsAsync(path, mode) diff --git a/service/src/get-cors-options.spec.ts b/service/src/get-cors-options.spec.ts index f464dc45..fbc56ac3 100644 --- a/service/src/get-cors-options.spec.ts +++ b/service/src/get-cors-options.spec.ts @@ -7,7 +7,7 @@ describe('getCorsOptions', () => { credentials: true, origins: ['http://localhost:8080'], headers: ['cache', 'content-type'], - methods: ['GET', 'POST', 'PATCH', 'DELETE'], + methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], }) }) }) diff --git a/service/src/get-cors-options.ts b/service/src/get-cors-options.ts index e676b323..8083b977 100644 --- a/service/src/get-cors-options.ts +++ b/service/src/get-cors-options.ts @@ -8,5 +8,5 @@ export const getCorsOptions = (): CorsOptions => ({ credentials: true, origins: ['http://localhost:8080'], headers: ['cache', 'content-type'], - methods: ['GET', 'POST', 'PATCH', 'DELETE'], + methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], }) diff --git a/yarn.lock b/yarn.lock index 591172a1..11090694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3583,7 +3583,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.12, glob@npm:^10.3.7": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.3.12 resolution: "glob@npm:10.3.12" dependencies: @@ -3612,6 +3612,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:^8.0.3": + version: 8.1.0 + resolution: "glob@npm:8.1.0" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^5.0.1" + once: "npm:^1.3.0" + checksum: 10c0/cb0b5cab17a59c57299376abe5646c7070f8acb89df5595b492dba3bfb43d301a46c01e5695f01154e6553168207cb60d4eaf07d3be4bc3eb9b0457c5c561d0f + languageName: node + linkType: hard + "global@npm:4.4.0, global@npm:^4.3.1, global@npm:^4.4.0, global@npm:~4.4.0": version: 4.4.0 resolution: "global@npm:4.4.0" @@ -4860,6 +4873,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 + languageName: node + linkType: hard + "minimatch@npm:^9.0.1, minimatch@npm:^9.0.4": version: 9.0.4 resolution: "minimatch@npm:9.0.4" @@ -6921,7 +6943,7 @@ __metadata: dependencies: "@types/json-schema": "npm:^7.0.15" commander: "npm:^12.0.0" - glob: "npm:^10.3.12" + glob: "npm:^8.0.3" json5: "npm:^2.2.3" normalize-path: "npm:^3.0.0" safe-stable-stringify: "npm:^2.4.3"