From d2706ffed8e16d0f564077929eb3720021ca54c4 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Fri, 12 Jul 2024 14:11:21 +0700 Subject: [PATCH] UBERF-7356 Drive file versions (#6049) Signed-off-by: Alexander Onnikov --- models/all/src/migration.ts | 2 + models/drive/src/index.ts | 191 +++++++++++++++--- models/drive/src/migration.ts | 102 ++++++++++ models/drive/src/plugin.ts | 15 +- models/server-drive/src/index.ts | 4 +- plugins/drive-assets/assets/icons.svg | 4 +- plugins/drive-assets/lang/en.json | 8 + plugins/drive-assets/lang/es.json | 8 + plugins/drive-assets/lang/fr.json | 11 +- plugins/drive-assets/lang/pt.json | 8 + plugins/drive-assets/lang/ru.json | 8 + plugins/drive-assets/lang/zh.json | 8 + plugins/drive-assets/src/index.ts | 3 +- .../src/components/DriveSpaceHeader.svelte | 4 +- .../src/components/EditFile.svelte | 35 +++- .../src/components/EditFileVersions.svelte | 45 +++++ .../src/components/FileAside.svelte | 6 +- .../src/components/FileDropArea.svelte | 7 +- .../src/components/FileHeader.svelte | 2 +- .../src/components/FileInput.svelte | 46 +++++ .../src/components/FilePanel.svelte | 61 +++++- .../src/components/FilePresenter.svelte | 16 +- .../components/FileVersionPresenter.svelte | 48 +++++ .../FileVersionVersionPresenter.svelte | 36 ++++ .../src/components/GridItem.svelte | 7 +- .../src/components/Thumbnail.svelte | 11 +- .../src/components/icons/FileDownload.svelte | 28 +++ .../src/components/icons/FileUpload.svelte | 28 +++ plugins/drive-resources/src/index.ts | 34 +++- plugins/drive-resources/src/navigation.ts | 8 +- plugins/drive-resources/src/plugin.ts | 5 +- plugins/drive-resources/src/utils.ts | 54 +++-- plugins/drive/src/index.ts | 1 + plugins/drive/src/plugin.ts | 9 +- plugins/drive/src/types.ts | 32 ++- plugins/drive/src/utils.ts | 98 +++++++++ server-plugins/drive-resources/src/index.ts | 25 +-- server-plugins/drive/src/index.ts | 2 +- 38 files changed, 880 insertions(+), 140 deletions(-) create mode 100644 models/drive/src/migration.ts create mode 100644 plugins/drive-resources/src/components/EditFileVersions.svelte create mode 100644 plugins/drive-resources/src/components/FileInput.svelte create mode 100644 plugins/drive-resources/src/components/FileVersionPresenter.svelte create mode 100644 plugins/drive-resources/src/components/FileVersionVersionPresenter.svelte create mode 100644 plugins/drive-resources/src/components/icons/FileDownload.svelte create mode 100644 plugins/drive-resources/src/components/icons/FileUpload.svelte create mode 100644 plugins/drive/src/utils.ts diff --git a/models/all/src/migration.ts b/models/all/src/migration.ts index 9983d5aae6..55bf9ca300 100644 --- a/models/all/src/migration.ts +++ b/models/all/src/migration.ts @@ -42,6 +42,7 @@ import { activityOperation } from '@hcengineering/model-activity' import { activityServerOperation } from '@hcengineering/model-server-activity' import { loveId, loveOperation } from '@hcengineering/model-love' import { documentOperation } from '@hcengineering/model-document' +import { driveOperation } from '@hcengineering/model-drive' import { textEditorOperation } from '@hcengineering/model-text-editor' import { questionsOperation } from '@hcengineering/model-questions' import { trainingOperation } from '@hcengineering/model-training' @@ -79,6 +80,7 @@ export const migrateOperations: [string, MigrateOperation][] = [ ['activityServer', activityServerOperation], [loveId, loveOperation], ['document', documentOperation], + ['drive', driveOperation], ['textEditor', textEditorOperation], // We should call it after activityServer and chunter ['notification', notificationOperation] diff --git a/models/drive/src/index.ts b/models/drive/src/index.ts index 293728896e..c7830fcc62 100644 --- a/models/drive/src/index.ts +++ b/models/drive/src/index.ts @@ -17,45 +17,62 @@ import activity from '@hcengineering/activity' import chunter from '@hcengineering/chunter' import core, { type Blob, + type Class, + type CollectionSize, type Domain, type FindOptions, + type Ref, type Role, type RolesAssignment, Account, AccountRole, IndexKind, - Ref, SortingOrder } from '@hcengineering/core' -import { type Drive, type File, type Folder, type Resource, driveId } from '@hcengineering/drive' +import { + type Drive, + type File, + type FileVersion, + type Folder, + type Resource, + TypeFileVersion, + driveId +} from '@hcengineering/drive' import { type Builder, + Collection, Hidden, Index, Mixin, Model, Prop, ReadOnly, + TypeFileSize, TypeRecord, TypeRef, TypeString, - UX, - Collection + TypeTimestamp, + UX } from '@hcengineering/model' -import { TDoc, TTypedSpace } from '@hcengineering/model-core' +import { TAttachedDoc, TDoc, TType, TTypedSpace } from '@hcengineering/model-core' import print from '@hcengineering/model-print' import tracker from '@hcengineering/model-tracker' -import view, { type Viewlet, actionTemplates, createAction } from '@hcengineering/model-view' +import view, { type Viewlet, actionTemplates, classPresenter, createAction } from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' import { getEmbeddedLabel } from '@hcengineering/platform' import drive from './plugin' export { driveId } from '@hcengineering/drive' +export { driveOperation } from './migration' export { drive as default } export const DOMAIN_DRIVE = 'drive' as Domain +@Model(drive.class.TypeFileVersion, core.class.Type) +@UX(core.string.Number) +export class TTypeFileVersion extends TType {} + @Model(drive.class.Drive, core.class.TypedSpace) @UX(drive.string.Drive) export class TDrive extends TTypedSpace implements Drive {} @@ -75,15 +92,6 @@ export class TResource extends TDoc implements Resource { @Index(IndexKind.FullText) name!: string - @Prop(TypeRef(core.class.Blob), drive.string.File) - @ReadOnly() - file?: Ref - - @Prop(TypeRef(core.class.Blob), drive.string.Preview) - @ReadOnly() - @Hidden() - preview?: Ref - @Prop(TypeRef(drive.class.Resource), drive.string.Parent) @Index(IndexKind.Indexed) @ReadOnly() @@ -95,6 +103,10 @@ export class TResource extends TDoc implements Resource { @Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments) comments?: number + + @Prop(TypeRef(drive.class.FileVersion), drive.string.Version) + @ReadOnly() + file?: Ref } @Model(drive.class.Folder, drive.class.Resource, DOMAIN_DRIVE) @@ -110,20 +122,11 @@ export class TFolder extends TResource implements Folder { declare path: Ref[] declare file: undefined - declare preview: undefined } @Model(drive.class.File, drive.class.Resource, DOMAIN_DRIVE) @UX(drive.string.File) export class TFile extends TResource implements File { - @Prop(TypeRef(core.class.Blob), drive.string.File) - @ReadOnly() - declare file: Ref - - @Prop(TypeRecord(), drive.string.Metadata) - @ReadOnly() - metadata?: Record - @Prop(TypeRef(drive.class.Folder), drive.string.Parent) @Index(IndexKind.Indexed) @ReadOnly() @@ -132,6 +135,75 @@ export class TFile extends TResource implements File { @Prop(TypeRef(drive.class.Folder), drive.string.Path) @ReadOnly() declare path: Ref[] + + @Prop(TypeRef(drive.class.FileVersion), drive.string.Version) + @ReadOnly() + declare file: Ref + + @Prop(Collection(drive.class.FileVersion), drive.string.FileVersion) + @ReadOnly() + versions!: CollectionSize + + @Prop(TypeFileVersion(), drive.string.Version) + @ReadOnly() + version!: number +} + +@Model(drive.class.FileVersion, core.class.AttachedDoc, DOMAIN_DRIVE) +@UX(drive.string.FileVersion) +export class TFileVersion extends TAttachedDoc implements FileVersion { + declare space: Ref + + @Prop(TypeRef(drive.class.File), core.string.AttachedTo) + @Index(IndexKind.Indexed) + declare attachedTo: Ref + + @Prop(TypeRef(core.class.Class), core.string.AttachedToClass) + @Index(IndexKind.Indexed) + declare attachedToClass: Ref> + + @Prop(TypeString(), core.string.Collection) + @Hidden() + override collection: 'versions' = 'versions' + + @Prop(TypeString(), drive.string.Name) + @Index(IndexKind.FullText) + name!: string + + @Prop(TypeRef(core.class.Blob), drive.string.File) + @ReadOnly() + file!: Ref + + @Prop(TypeFileSize(), drive.string.Size) + @ReadOnly() + size!: number + + @Prop(TypeString(), drive.string.ContentType) + @ReadOnly() + type!: string + + @Prop(TypeTimestamp(), drive.string.LastModified) + @ReadOnly() + lastModified!: number + + @Prop(TypeRecord(), drive.string.Metadata) + @ReadOnly() + metadata?: Record + + @Prop(TypeFileVersion(), drive.string.Version) + @ReadOnly() + version!: number +} + +function defineTypes (builder: Builder): void { + builder.createModel(TTypeFileVersion) + + classPresenter( + builder, + drive.class.TypeFileVersion, + drive.component.FileVersionVersionPresenter, + drive.component.FileVersionVersionPresenter + ) } function defineDrive (builder: Builder): void { @@ -266,21 +338,20 @@ function defineResource (builder: Builder): void { }, '$lookup.file.size', 'comments', - '$lookup.file.modifiedOn', + '$lookup.file.lastModified', 'createdBy' ], /* eslint-disable @typescript-eslint/consistent-type-assertions */ options: { lookup: { - file: core.class.Blob, - preview: core.class.Blob + file: drive.class.FileVersion }, sort: { _class: SortingOrder.Descending } } as FindOptions, configOptions: { - hiddenKeys: ['name', 'file', 'parent', 'path', 'type'], + hiddenKeys: ['name', 'parent', 'path', 'file', 'versions'], sortable: true } }, @@ -325,14 +396,13 @@ function defineResource (builder: Builder): void { 'createdBy' ], configOptions: { - hiddenKeys: ['name', 'file', 'parent', 'path'], + hiddenKeys: ['name', 'parent', 'path', 'file', 'versions'], sortable: true }, /* eslint-disable @typescript-eslint/consistent-type-assertions */ options: { lookup: { - file: core.class.Blob, - preview: core.class.Blob + file: drive.class.FileVersion }, sort: { _class: SortingOrder.Descending @@ -429,6 +499,45 @@ function defineFolder (builder: Builder): void { }) } +function defineFileVersion (builder: Builder): void { + builder.createModel(TFileVersion) + + builder.mixin(drive.class.FileVersion, core.class.Class, view.mixin.ObjectPresenter, { + presenter: drive.component.FileVersionPresenter + }) + + // Actions + + builder.mixin(drive.class.FileVersion, core.class.Class, view.mixin.IgnoreActions, { + actions: [ + view.action.Open, + view.action.OpenInNewTab, + view.action.Delete, + print.action.Print, + tracker.action.EditRelatedTargets, + tracker.action.NewRelatedIssue + ] + }) + + createAction( + builder, + { + action: drive.actionImpl.RestoreFileVersion, + label: drive.string.Restore, + icon: drive.icon.Restore, + category: drive.category.Drive, + input: 'none', + target: drive.class.FileVersion, + context: { + mode: ['context', 'browser'], + application: drive.app.Drive, + group: 'edit' + } + }, + drive.action.RestoreFileVersion + ) +} + function defineFile (builder: Builder): void { builder.createModel(TFile) @@ -506,6 +615,24 @@ function defineFile (builder: Builder): void { drive.action.RenameFile ) + // createAction( + // builder, + // { + // action: drive.actionImpl.UploadFile, + // label: drive.string.UploadFile, + // icon: drive.icon.File, + // category: drive.category.Drive, + // input: 'focus', + // target: drive.class.File, + // context: { + // mode: ['context', 'browser'], + // application: drive.app.Drive, + // group: 'tools' + // } + // }, + // drive.action.UploadFile + // ) + createAction(builder, { ...actionTemplates.move, action: view.actionImpl.ShowPopup, @@ -580,9 +707,11 @@ export function createModel (builder: Builder): void { drive.viewlet.Grid ) + defineTypes(builder) defineDrive(builder) defineResource(builder) defineFolder(builder) defineFile(builder) + defineFileVersion(builder) defineApplication(builder) } diff --git a/models/drive/src/migration.ts b/models/drive/src/migration.ts new file mode 100644 index 0000000000..529aafa865 --- /dev/null +++ b/models/drive/src/migration.ts @@ -0,0 +1,102 @@ +// +// Copyright © 2022-2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { type Blob, type Ref, DOMAIN_BLOB, generateId, toIdMap } from '@hcengineering/core' +import type { File, FileVersion } from '@hcengineering/drive' +import { + type MigrateOperation, + type MigrationClient, + type MigrationUpgradeClient, + tryMigrate, + tryUpgrade +} from '@hcengineering/model' + +import drive, { DOMAIN_DRIVE, driveId } from './index' + +async function migrateFileVersions (client: MigrationClient): Promise { + type ExFile = Omit & { + file: Ref + metadata?: Record + } + + const files = await client.find(DOMAIN_DRIVE, { + _class: drive.class.File, + version: { $exists: false } + }) + + const blobIds = files.map((p) => (p as unknown as ExFile).file) + const blobs = await client.find(DOMAIN_BLOB, { _id: { $in: blobIds }, _class: core.class.Blob }) + const blobsById = toIdMap(blobs) + + for (const file of files) { + const exfile = file as unknown as ExFile + + const blob = blobsById.get(exfile.file) + if (blob === undefined) continue + + const fileVersionId: Ref = generateId() + + await client.create(DOMAIN_DRIVE, { + _id: fileVersionId, + _class: drive.class.FileVersion, + attachedTo: file._id, + attachedToClass: file._class, + collection: 'versions', + modifiedOn: file.modifiedOn, + modifiedBy: file.modifiedBy, + space: file.space, + name: exfile.name, + file: blob._id, + size: blob.size, + lastModified: blob.modifiedOn, + type: blob.contentType, + metadata: exfile.metadata, + version: 1 + }) + + await client.update( + DOMAIN_DRIVE, + { + _id: file._id, + _class: file._class + }, + { + $set: { + version: 1, + versions: 1, + file: fileVersionId + }, + $unset: { + metadata: 1 + } + } + ) + } +} + +export const driveOperation: MigrateOperation = { + async migrate (client: MigrationClient): Promise { + await tryMigrate(client, driveId, [ + { + state: 'file-versions', + func: migrateFileVersions + } + ]) + }, + + async upgrade (state: Map>, client: () => Promise): Promise { + await tryUpgrade(state, client, driveId, []) + } +} diff --git a/models/drive/src/plugin.ts b/models/drive/src/plugin.ts index 74020eb196..2de322b187 100644 --- a/models/drive/src/plugin.ts +++ b/models/drive/src/plugin.ts @@ -43,6 +43,8 @@ export default mergeIds(driveId, drive, { FolderPresenter: '' as AnyComponent, FilePresenter: '' as AnyComponent, FileSizePresenter: '' as AnyComponent, + FileVersionPresenter: '' as AnyComponent, + FileVersionVersionPresenter: '' as AnyComponent, MoveResource: '' as AnyComponent, ResourcePresenter: '' as AnyComponent }, @@ -68,7 +70,8 @@ export default mergeIds(driveId, drive, { EditDrive: '' as Ref, DownloadFile: '' as Ref, RenameFile: '' as Ref, - RenameFolder: '' as Ref + RenameFolder: '' as Ref, + RestoreFileVersion: '' as Ref }, actionImpl: { CreateChildFolder: '' as ViewAction, @@ -76,17 +79,21 @@ export default mergeIds(driveId, drive, { EditDrive: '' as ViewAction, DownloadFile: '' as ViewAction, RenameFile: '' as ViewAction, - RenameFolder: '' as ViewAction + RenameFolder: '' as ViewAction, + RestoreFileVersion: '' as ViewAction }, string: { Grid: '' as IntlString, Name: '' as IntlString, Description: '' as IntlString, Metadata: '' as IntlString, + ContentType: '' as IntlString, + Size: '' as IntlString, + LastModified: '' as IntlString, Parent: '' as IntlString, Path: '' as IntlString, Drives: '' as IntlString, - Download: '' as IntlString, - Preview: '' as IntlString + Version: '' as IntlString, + Restore: '' as IntlString } }) diff --git a/models/server-drive/src/index.ts b/models/server-drive/src/index.ts index 30310be4fc..817c57b30b 100644 --- a/models/server-drive/src/index.ts +++ b/models/server-drive/src/index.ts @@ -24,10 +24,10 @@ export { serverDriveId } from '@hcengineering/server-drive' export function createModel (builder: Builder): void { builder.createDoc(serverCore.class.Trigger, core.space.Model, { - trigger: serverDrive.trigger.OnFileDelete, + trigger: serverDrive.trigger.OnFileVersionDelete, txMatch: { _class: core.class.TxRemoveDoc, - objectClass: drive.class.File + objectClass: drive.class.FileVersion } }) diff --git a/plugins/drive-assets/assets/icons.svg b/plugins/drive-assets/assets/icons.svg index 176c6d9f57..3451ec039f 100644 --- a/plugins/drive-assets/assets/icons.svg +++ b/plugins/drive-assets/assets/icons.svg @@ -24,5 +24,7 @@ - + + + diff --git a/plugins/drive-assets/lang/en.json b/plugins/drive-assets/lang/en.json index f57b021d25..bbb0b4c9b1 100644 --- a/plugins/drive-assets/lang/en.json +++ b/plugins/drive-assets/lang/en.json @@ -4,19 +4,27 @@ "Drives": "Drives", "Grid": "Grid", "File": "File", + "FileVersion": "File version", + "FileVersions": "File versions", "Folder": "Folder", "Resource": "Resource", "Name": "Name", "Description": "Description", "Parent": "Parent", "Path": "Path", + "Version": "Version", + "Size": "Size", + "ContentType": "Content type", + "LastModified": "Last Modified", "Download": "Download", + "Upload": "Upload", "CreateDrive": "Create Drive", "CreateFolder": "Create Folder", "UploadFile": "Upload File", "UploadFolder": "Upload Folder", "EditDrive": "Edit Drive", "Rename": "Rename", + "Restore": "Restore", "RoleLabel": "Role", "Root": "/" } diff --git a/plugins/drive-assets/lang/es.json b/plugins/drive-assets/lang/es.json index 9339723ebd..a79eeeefd3 100644 --- a/plugins/drive-assets/lang/es.json +++ b/plugins/drive-assets/lang/es.json @@ -4,19 +4,27 @@ "Drives": "Unidades", "Grid": "Red", "File": "Archivo", + "FileVersion": "Versión del archivo", + "FileVersions": "Versiones de archivos", "Folder": "Carpeta", "Resource": "Recurso", "Name": "Nombre", "Description": "Descripción", "Parent": "Padre", "Path": "Ruta", + "Version": "Versión", + "Size": "Tamaño", + "ContentType": "Tipo de contenido", + "LastModified": "Última modificación", "Download": "Descargar", + "Upload": "Subir", "CreateDrive": "Crear unidad", "CreateFolder": "Crear carpeta", "UploadFile": "Subir archivo", "UploadFolder": "Subir carpeta", "EditDrive": "Editar unidad", "Rename": "Renombrar", + "Restore": "Restaurar", "RoleLabel": "Rol", "Root": "/" } diff --git a/plugins/drive-assets/lang/fr.json b/plugins/drive-assets/lang/fr.json index 796e29eaf7..61a79d2642 100644 --- a/plugins/drive-assets/lang/fr.json +++ b/plugins/drive-assets/lang/fr.json @@ -4,22 +4,27 @@ "Drives": "Disques", "Grid": "Grille", "File": "Fichier", + "FileVersion": "Version du fichier", + "FileVersions": "Versions de fichiers", "Folder": "Dossier", "Resource": "Ressource", "Name": "Nom", "Description": "Description", - "Size": "Taille", - "Type": "Type", - "LastModified": "Dernière modification", "Parent": "Parent", "Path": "Chemin", + "Version": "Version", + "Size": "Taille", + "ContentType": "Type", + "LastModified": "Dernière modification", "Download": "Télécharger", + "Upload": "Téléverser", "CreateDrive": "Créer un disque", "CreateFolder": "Créer un dossier", "UploadFile": "Télécharger un fichier", "UploadFolder": "Télécharger un dossier", "EditDrive": "Modifier le disque", "Rename": "Renommer", + "Restore": "Restaurer", "RoleLabel": "Rôle", "Root": "/" } diff --git a/plugins/drive-assets/lang/pt.json b/plugins/drive-assets/lang/pt.json index 50fc53da37..9822a424ee 100644 --- a/plugins/drive-assets/lang/pt.json +++ b/plugins/drive-assets/lang/pt.json @@ -4,19 +4,27 @@ "Drives": "Unidades", "Grid": "Grade", "File": "Ficheiro", + "FileVersion": "Versão do ficheiro", + "FileVersions": "Versões de ficheiro", "Folder": "Pasta", "Resource": "Recurso", "Name": "Nome", "Description": "Descrição", "Parent": "Pai", "Path": "Caminho", + "Version": "Versão", + "Size": "Tamanho", + "ContentType": "Tipo de conteúdo", + "LastModified": "Última modificação", "Download": "Descarregar", + "Upload": "Carregar", "CreateDrive": "Criar unidade", "CreateFolder": "Criar pasta", "UploadFile": "Carregar ficheiro", "UploadFolder": "Carregar pasta", "EditDrive": "Editar unidade", "Rename": "Renomear", + "Restore": "Restaurar", "RoleLabel": "Papel", "Root": "/" } diff --git a/plugins/drive-assets/lang/ru.json b/plugins/drive-assets/lang/ru.json index 59ece3d638..9d7c4ea9cc 100644 --- a/plugins/drive-assets/lang/ru.json +++ b/plugins/drive-assets/lang/ru.json @@ -4,19 +4,27 @@ "Drives": "Диски", "Grid": "Сетка", "File": "Файл", + "FileVersion": "Версия файла", + "FileVersions": "Версии файла", "Folder": "Папка", "Resource": "Ресурс", "Name": "Название", "Description": "Описание", "Parent": "Родительская папка", "Path": "Путь", + "Version": "Версия", + "Size": "Размер", + "ContentType": "Тип содержимого", + "LastModified": "Последнее изменение", "Download": "Скачать", + "Upload": "Загрузить", "CreateDrive": "Создать диск", "CreateFolder": "Создать папку", "UploadFile": "Загрузить файл", "UploadFolder": "Загрузить папку", "EditDrive": "Редактировать", "Rename": "Переименовать", + "Restore": "Восстановить", "RoleLabel": "Роль", "Root": "/" } diff --git a/plugins/drive-assets/lang/zh.json b/plugins/drive-assets/lang/zh.json index 51054b2097..1359f4c65f 100644 --- a/plugins/drive-assets/lang/zh.json +++ b/plugins/drive-assets/lang/zh.json @@ -4,19 +4,27 @@ "Drives": "磁盘", "Grid": "网格", "File": "文件", + "FileVersion": "檔案版本", + "FileVersions": "檔案版本", "Folder": "文件夹", "Resource": "资源", "Name": "名称", "Description": "描述", "Parent": "父级", "Path": "路径", + "Version": "版本", + "Size": "大小", + "ContentType": "内容类型", + "LastModified": "最后修改", "Download": "下载", + "Upload": "上傳", "CreateDrive": "创建磁盘", "CreateFolder": "创建文件夹", "UploadFile": "上传文件", "UploadFolder": "上传文件夹", "EditDrive": "编辑磁盘", "Rename": "重命名", + "Restore": "恢复", "RoleLabel": "角色", "Root": "/" } diff --git a/plugins/drive-assets/src/index.ts b/plugins/drive-assets/src/index.ts index 3c9e2ce8fe..eb1fbdb4e5 100644 --- a/plugins/drive-assets/src/index.ts +++ b/plugins/drive-assets/src/index.ts @@ -24,5 +24,6 @@ loadMetadata(drive.icon, { Folder: `${icons}#folder`, FolderOpen: `${icons}#folder-open`, FolderClosed: `${icons}#folder-closed`, - Download: `${icons}#download` + Download: `${icons}#download`, + Restore: `${icons}#restore` }) diff --git a/plugins/drive-resources/src/components/DriveSpaceHeader.svelte b/plugins/drive-resources/src/components/DriveSpaceHeader.svelte index cd27d30bcc..223cc12f25 100644 --- a/plugins/drive-resources/src/components/DriveSpaceHeader.svelte +++ b/plugins/drive-resources/src/components/DriveSpaceHeader.svelte @@ -20,7 +20,7 @@ import drive from '../plugin' import { getFolderIdFromFragment } from '../navigation' - import { createDrive, createFolder, createFiles } from '../utils' + import { createDrive, createFolder, uploadFiles } from '../utils' export let currentSpace: Ref | undefined export let currentFragment: string | undefined @@ -95,7 +95,7 @@ progress = true - await createFiles(list, currentSpace, parent) + await uploadFiles(list, currentSpace, parent) inputFile.value = '' progress = false diff --git a/plugins/drive-resources/src/components/EditFile.svelte b/plugins/drive-resources/src/components/EditFile.svelte index 7ab64ff5a1..20f75b0c20 100644 --- a/plugins/drive-resources/src/components/EditFile.svelte +++ b/plugins/drive-resources/src/components/EditFile.svelte @@ -13,11 +13,12 @@ // limitations under the License. --> -{#if object !== undefined} +{#if object !== undefined && version !== undefined} {#if blob !== undefined} - + + {/if} + + {#if object.versions > 1} +
+ +
{/if} {/if} diff --git a/plugins/drive-resources/src/components/EditFileVersions.svelte b/plugins/drive-resources/src/components/EditFileVersions.svelte new file mode 100644 index 0000000000..f1157bff81 --- /dev/null +++ b/plugins/drive-resources/src/components/EditFileVersions.svelte @@ -0,0 +1,45 @@ + + + +{#if object.versions > 1} +
+ + + + + + +{/if} diff --git a/plugins/drive-resources/src/components/FileAside.svelte b/plugins/drive-resources/src/components/FileAside.svelte index 2c8b58fc9e..711064899d 100644 --- a/plugins/drive-resources/src/components/FileAside.svelte +++ b/plugins/drive-resources/src/components/FileAside.svelte @@ -26,11 +26,7 @@ {#if object.$lookup?.file} - + {/if}
diff --git a/plugins/drive-resources/src/components/FileDropArea.svelte b/plugins/drive-resources/src/components/FileDropArea.svelte index 6861c45314..6687f3e2a3 100644 --- a/plugins/drive-resources/src/components/FileDropArea.svelte +++ b/plugins/drive-resources/src/components/FileDropArea.svelte @@ -16,7 +16,7 @@ import { type Ref } from '@hcengineering/core' import { type Drive, type Folder } from '@hcengineering/drive' - import { createFiles } from '../utils' + import { uploadFiles } from '../utils' export let space: Ref export let parent: Ref @@ -39,6 +39,9 @@ } async function handleDragOver (e: DragEvent): Promise { + if (e.dataTransfer?.files === undefined) { + return + } if (canDrop !== undefined && !canDrop(e)) { return } @@ -63,7 +66,7 @@ // progress = true const list = e.dataTransfer?.files if (list !== undefined && list.length !== 0) { - await createFiles(list, space, parent) + await uploadFiles(list, space, parent) } // progress = false } diff --git a/plugins/drive-resources/src/components/FileHeader.svelte b/plugins/drive-resources/src/components/FileHeader.svelte index 7213f98abd..2e33c13908 100644 --- a/plugins/drive-resources/src/components/FileHeader.svelte +++ b/plugins/drive-resources/src/components/FileHeader.svelte @@ -33,5 +33,5 @@
- +
diff --git a/plugins/drive-resources/src/components/FileInput.svelte b/plugins/drive-resources/src/components/FileInput.svelte new file mode 100644 index 0000000000..0c975e2ced --- /dev/null +++ b/plugins/drive-resources/src/components/FileInput.svelte @@ -0,0 +1,46 @@ + + + + diff --git a/plugins/drive-resources/src/components/FilePanel.svelte b/plugins/drive-resources/src/components/FilePanel.svelte index bad1059503..9deb6dd177 100644 --- a/plugins/drive-resources/src/components/FilePanel.svelte +++ b/plugins/drive-resources/src/components/FilePanel.svelte @@ -13,10 +13,10 @@ // limitations under the License. --> -{#if object} +{#if object && version} + + - {#await getBlobHref(object.$lookup?.file, object.file, object.name) then href} + {#await getBlobHref(undefined, version.file, object.name) then href}
{/if} - - {value.name} - +
+ {value.name} + {#if shouldShowVersion} + + {formatFileVersion(value.version)} + {/if} +
{:else if type === 'text'} diff --git a/plugins/drive-resources/src/components/FileVersionPresenter.svelte b/plugins/drive-resources/src/components/FileVersionPresenter.svelte new file mode 100644 index 0000000000..3f370ccee0 --- /dev/null +++ b/plugins/drive-resources/src/components/FileVersionPresenter.svelte @@ -0,0 +1,48 @@ + + + +{#if value} + {#if inline} + + {:else if type === 'link'} + +
+
+ {version} +
+
+
+ {:else if type === 'text'} + {version} + {/if} +{/if} diff --git a/plugins/drive-resources/src/components/FileVersionVersionPresenter.svelte b/plugins/drive-resources/src/components/FileVersionVersionPresenter.svelte new file mode 100644 index 0000000000..b9f584fe04 --- /dev/null +++ b/plugins/drive-resources/src/components/FileVersionVersionPresenter.svelte @@ -0,0 +1,36 @@ + + + +{#if kind === 'link'} + +{:else} + {version} +{/if} diff --git a/plugins/drive-resources/src/components/GridItem.svelte b/plugins/drive-resources/src/components/GridItem.svelte index 06fc245689..4654b310b5 100644 --- a/plugins/drive-resources/src/components/GridItem.svelte +++ b/plugins/drive-resources/src/components/GridItem.svelte @@ -31,6 +31,8 @@ const hierarchy = client.getHierarchy() const dispatch = createEventDispatcher() + $: version = object.$lookup?.file + let hovered = false @@ -39,6 +41,7 @@ class="card-container" class:selected class:hovered + draggable="false" on:mouseover={() => dispatch('obj-focus', object)} on:mouseenter={() => dispatch('obj-focus', object)} on:focus={() => {}} @@ -88,11 +91,11 @@ /> - +
- +
diff --git a/plugins/drive-resources/src/components/Thumbnail.svelte b/plugins/drive-resources/src/components/Thumbnail.svelte index a2de341c81..8e6c875a77 100644 --- a/plugins/drive-resources/src/components/Thumbnail.svelte +++ b/plugins/drive-resources/src/components/Thumbnail.svelte @@ -35,17 +35,18 @@ let isImage = false let isError = false - $: previewBlob = object.$lookup?.preview ?? object.$lookup?.file - $: previewRef = object.$lookup?.preview !== undefined ? object.preview : object.file - $: isImage = previewBlob?.contentType?.startsWith('image/') ?? false + $: version = object.$lookup?.file + $: previewRef = version?.file + $: isImage = version?.type?.startsWith('image/') ?? false $: isFolder = hierarchy.isDerived(object._class, drive.class.Folder) {#if isFolder} -{:else if previewBlob != null && previewRef != null && isImage && !isError} - {#await getBlobRef(previewBlob, previewRef, object.name, sizeToWidth(size)) then blobSrc} +{:else if previewRef != null && isImage && !isError} + {#await getBlobRef(undefined, previewRef, object.name, sizeToWidth(size)) then blobSrc} + + + + + + + diff --git a/plugins/drive-resources/src/components/icons/FileUpload.svelte b/plugins/drive-resources/src/components/icons/FileUpload.svelte new file mode 100644 index 0000000000..9b40e335d1 --- /dev/null +++ b/plugins/drive-resources/src/components/icons/FileUpload.svelte @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/plugins/drive-resources/src/index.ts b/plugins/drive-resources/src/index.ts index b1941a4f3e..d71d619a8c 100644 --- a/plugins/drive-resources/src/index.ts +++ b/plugins/drive-resources/src/index.ts @@ -14,7 +14,7 @@ // import { type Doc, type Ref, type WithLookup } from '@hcengineering/core' -import drive, { type Drive, type File, type Folder } from '@hcengineering/drive' +import drive, { type Drive, type File, type FileVersion, type Folder } from '@hcengineering/drive' import { type Resources } from '@hcengineering/platform' import { getBlobHref } from '@hcengineering/presentation' import { showPopup, type Location } from '@hcengineering/ui' @@ -29,6 +29,8 @@ import EditFolder from './components/EditFolder.svelte' import FilePanel from './components/FilePanel.svelte' import FilePresenter from './components/FilePresenter.svelte' import FileSizePresenter from './components/FileSizePresenter.svelte' +import FileVersionPresenter from './components/FileVersionPresenter.svelte' +import FileVersionVersionPresenter from './components/FileVersionVersionPresenter.svelte' import FolderPanel from './components/FolderPanel.svelte' import FolderPresenter from './components/FolderPresenter.svelte' import GridView from './components/GridView.svelte' @@ -36,7 +38,7 @@ import MoveResource from './components/MoveResource.svelte' import ResourcePresenter from './components/ResourcePresenter.svelte' import { getDriveLink, getFileLink, getFolderLink, resolveLocation } from './navigation' -import { createFolder, renameResource } from './utils' +import { createFolder, renameResource, restoreFileVersion } from './utils' async function CreateRootFolder (doc: Drive): Promise { await createFolder(doc._id, drive.ids.Root) @@ -53,13 +55,16 @@ async function EditDrive (drive: Drive): Promise { async function DownloadFile (doc: WithLookup | Array>): Promise { const files = Array.isArray(doc) ? doc : [doc] for (const file of files) { - const href = await getBlobHref(file.$lookup?.file, file.file, file.name) - const link = document.createElement('a') - link.style.display = 'none' - link.target = '_blank' - link.href = href - link.download = file.name - link.click() + const version = file.$lookup?.file + if (version != null) { + const href = await getBlobHref(undefined, version.file, version.name) + const link = document.createElement('a') + link.style.display = 'none' + link.target = '_blank' + link.href = href + link.download = file.name + link.click() + } } } @@ -87,6 +92,12 @@ async function RenameFolder (doc: Folder | Folder[]): Promise { } } +async function RestoreFileVersion (doc: FileVersion | FileVersion[]): Promise { + if (!Array.isArray(doc)) { + await restoreFileVersion(doc) + } +} + export async function CanRenameFile (doc: File | File[] | undefined): Promise { return doc !== undefined && !Array.isArray(doc) } @@ -107,6 +118,8 @@ export default async (): Promise => ({ FilePanel, FilePresenter, FileSizePresenter, + FileVersionPresenter, + FileVersionVersionPresenter, FolderPanel, FolderPresenter, GridView, @@ -119,7 +132,8 @@ export default async (): Promise => ({ EditDrive, DownloadFile, RenameFile, - RenameFolder + RenameFolder, + RestoreFileVersion }, function: { DriveLinkProvider, diff --git a/plugins/drive-resources/src/navigation.ts b/plugins/drive-resources/src/navigation.ts index 1cc541cbe5..b60bb0b957 100644 --- a/plugins/drive-resources/src/navigation.ts +++ b/plugins/drive-resources/src/navigation.ts @@ -105,11 +105,11 @@ export async function generateFolderLocation (loc: Location, id: Ref): P return { loc: { - path: [appComponent, workspace, driveId, doc.space], + path: [appComponent, workspace, driveId], fragment: getPanelFragment(doc) }, defaultLocation: { - path: [appComponent, workspace, driveId], + path: [appComponent, workspace, driveId, doc.space], fragment: getPanelFragment(doc) } } @@ -130,11 +130,11 @@ export async function generateFileLocation (loc: Location, id: Ref): Promi return { loc: { - path: [appComponent, workspace, driveId, doc.space], + path: [appComponent, workspace, driveId], fragment: getPanelFragment(doc) }, defaultLocation: { - path: [appComponent, workspace, driveId], + path: [appComponent, workspace, driveId, doc.space], fragment: getPanelFragment(doc) } } diff --git a/plugins/drive-resources/src/plugin.ts b/plugins/drive-resources/src/plugin.ts index bf9aa28714..929d618b71 100644 --- a/plugins/drive-resources/src/plugin.ts +++ b/plugins/drive-resources/src/plugin.ts @@ -23,9 +23,12 @@ export default mergeIds(driveId, drive, { CreateFolder: '' as IntlString, UploadFile: '' as IntlString, UploadFolder: '' as IntlString, + Download: '' as IntlString, + Upload: '' as IntlString, EditDrive: '' as IntlString, Rename: '' as IntlString, RoleLabel: '' as IntlString, - Root: '' as IntlString + Root: '' as IntlString, + FileVersions: '' as IntlString } }) diff --git a/plugins/drive-resources/src/utils.ts b/plugins/drive-resources/src/utils.ts index 21d198831f..f1ec7c3387 100644 --- a/plugins/drive-resources/src/utils.ts +++ b/plugins/drive-resources/src/utils.ts @@ -13,8 +13,9 @@ // limitations under the License. // -import { toIdMap, type Class, type Doc, type Ref } from '@hcengineering/core' -import drive, { type Drive, type Folder, type Resource } from '@hcengineering/drive' +import { type Class, type Doc, type Ref, toIdMap } from '@hcengineering/core' +import type { Drive, File as DriveFile, FileVersion, Folder, Resource } from '@hcengineering/drive' +import drive, { createFile, createFileVersion } from '@hcengineering/drive' import { type Asset, setPlatformStatus, unknownError } from '@hcengineering/platform' import { getClient, getFileMetadata, uploadFile } from '@hcengineering/presentation' import { type AnySvelteComponent, showPopup } from '@hcengineering/ui' @@ -38,6 +39,10 @@ async function navigateToDoc (_id: Ref, _class: Ref>): Promise | undefined, parent: Ref, open = false): Promise { showPopup(CreateFolder, { space, parent }, 'top', async (id) => { if (open && id !== undefined && id !== null) { @@ -58,32 +63,42 @@ export async function editDrive (drive: Drive): Promise { showPopup(CreateDrive, { drive }) } -export async function createFiles (list: FileList, space: Ref, parent: Ref): Promise { - const client = getClient() - const folder = await client.findOne(drive.class.Folder, { space, _id: parent }) - +export async function uploadFiles (list: FileList, space: Ref, parent: Ref): Promise { for (let index = 0; index < list.length; index++) { const file = list.item(index) if (file !== null) { - await createFile(file, space, folder) + await uploadOneFile(file, space, parent) } } } -export async function createFile (file: File, space: Ref, parent: Folder | undefined): Promise { +export async function uploadOneFile (file: File, space: Ref, parent: Ref): Promise { const client = getClient() try { const uuid = await uploadFile(file) const metadata = await getFileMetadata(file, uuid) - await client.createDoc(drive.class.File, space, { - name: file.name, - file: uuid, - metadata, - parent: parent?._id ?? drive.ids.Root, - path: parent !== undefined ? [parent._id, ...parent.path] : [] - }) + const { name, size, type, lastModified } = file + const data = { file: uuid, name, size, type, lastModified, metadata } + + await createFile(client, space, parent, data) + } catch (e) { + void setPlatformStatus(unknownError(e)) + } +} + +export async function replaceOneFile (existing: Ref, file: File): Promise { + const client = getClient() + + try { + const uuid = await uploadFile(file) + const metadata = await getFileMetadata(file, uuid) + + const { name, size, type, lastModified } = file + const data = { file: uuid, name, size, type, lastModified, metadata } + + await createFileVersion(client, existing, data) } catch (e) { void setPlatformStatus(unknownError(e)) } @@ -130,6 +145,15 @@ export async function moveResources (resources: Resource[], space: Ref, p await ops.commit() } +export async function restoreFileVersion (version: FileVersion): Promise { + const client = getClient() + + const file = await client.findOne(drive.class.File, { _id: version.attachedTo }) + if (file !== undefined && file.file !== version._id) { + await client.diffUpdate(file, { file: version._id }) + } +} + const fileTypesMap: Record = { 'application/pdf': FileTypePdf, audio: FileTypeAudio, diff --git a/plugins/drive/src/index.ts b/plugins/drive/src/index.ts index 3f99fc6ba1..69f17d476c 100644 --- a/plugins/drive/src/index.ts +++ b/plugins/drive/src/index.ts @@ -16,6 +16,7 @@ import { driveId, drivePlugin } from './plugin' export * from './types' +export * from './utils' export { driveId } export default drivePlugin diff --git a/plugins/drive/src/plugin.ts b/plugins/drive/src/plugin.ts index 9bedc6436e..f3a8cde8e7 100644 --- a/plugins/drive/src/plugin.ts +++ b/plugins/drive/src/plugin.ts @@ -17,7 +17,7 @@ import type { Class, Doc, Mixin, Ref, SpaceType, SpaceTypeDescriptor, Type } fro import type { Asset, IntlString, Plugin, Resource as PlatformResource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import type { Location, ResolvedLocation } from '@hcengineering/ui' -import { Drive, File, FileSize, Folder, Resource } from './types' +import { Drive, File, FileVersion, Folder, Resource } from './types' export * from './types' @@ -30,9 +30,10 @@ export const drivePlugin = plugin(driveId, { class: { Drive: '' as Ref>, File: '' as Ref>, + FileVersion: '' as Ref>, Folder: '' as Ref>, Resource: '' as Ref>, - TypeFileSize: '' as Ref>> + TypeFileVersion: '' as Ref>> }, mixin: { DefaultDriveTypeData: '' as Ref> @@ -44,7 +45,8 @@ export const drivePlugin = plugin(driveId, { Folder: '' as Asset, FolderOpen: '' as Asset, FolderClosed: '' as Asset, - Download: '' as Asset + Download: '' as Asset, + Restore: '' as Asset }, app: { Drive: '' as Ref @@ -58,6 +60,7 @@ export const drivePlugin = plugin(driveId, { string: { Drive: '' as IntlString, File: '' as IntlString, + FileVersion: '' as IntlString, Folder: '' as IntlString, Resource: '' as IntlString }, diff --git a/plugins/drive/src/types.ts b/plugins/drive/src/types.ts index 8eed5768f8..f087c73ddd 100644 --- a/plugins/drive/src/types.ts +++ b/plugins/drive/src/types.ts @@ -13,10 +13,14 @@ // limitations under the License. // -import { Blob, Doc, Ref, TypedSpace } from '@hcengineering/core' +import { AttachedDoc, Blob, CollectionSize, Doc, Ref, Type, TypedSpace } from '@hcengineering/core' + +import drive from './plugin' /** @public */ -export type FileSize = number +export function TypeFileVersion (): Type { + return { _class: drive.class.TypeFileVersion, label: drive.string.FileVersion } +} /** @public */ export interface Drive extends TypedSpace {} @@ -24,13 +28,13 @@ export interface Drive extends TypedSpace {} /** @public */ export interface Resource extends Doc { name: string - file?: Ref - preview?: Ref - parent: Ref path: Ref[] comments?: number + + // ugly but needed here to get version lookup work for Resource + file?: Ref } /** @public */ @@ -43,9 +47,21 @@ export interface Folder extends Resource { /** @public */ export interface File extends Resource { - file: Ref - metadata?: Record - parent: Ref path: Ref[] + + file: Ref + versions: CollectionSize + version: number +} + +/** @public */ +export interface FileVersion extends AttachedDoc { + name: string + file: Ref + size: number + type: string + lastModified: number + metadata?: Record + version: number } diff --git a/plugins/drive/src/utils.ts b/plugins/drive/src/utils.ts new file mode 100644 index 0000000000..170107c69c --- /dev/null +++ b/plugins/drive/src/utils.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type AttachedData, type Ref, type TxOperations, generateId } from '@hcengineering/core' + +import drive from './plugin' +import type { Drive, File, FileVersion, Folder } from './types' + +/** @public */ +export async function createFile ( + client: TxOperations, + space: Ref, + parent: Ref, + data: Omit, 'version'> +): Promise { + const folder = await client.findOne(drive.class.Folder, { _id: parent }) + const path = folder !== undefined ? [folder._id, ...folder.path] : [] + + const version = 1 + const versionId: Ref = generateId() + + const fileId = await client.createDoc(drive.class.File, space, { + name: data.name, + parent, + path, + file: versionId, + version, + versions: 0 + }) + + await client.addCollection( + drive.class.FileVersion, + space, + fileId, + drive.class.File, + 'versions', + { ...data, version }, + versionId + ) +} + +/** @public */ +export async function createFileVersion ( + client: TxOperations, + file: Ref, + data: Omit, 'version'> +): Promise { + const current = await client.findOne(drive.class.File, { _id: file }) + if (current === undefined) { + throw new Error('file not found') + } + + const incResult = await client.update(current, { $inc: { version: 1 } }, true) + const version = (incResult as any).object.version + + const ops = client.apply(file) + + const versionId = await ops.addCollection( + drive.class.FileVersion, + current.space, + current._id, + current._class, + 'versions', + { ...data, version } + ) + + await ops.update(current, { file: versionId }) + + await ops.commit() +} + +/** @public */ +export async function restoreFileVersion ( + client: TxOperations, + file: Ref, + version: Ref +): Promise { + const currentFile = await client.findOne(drive.class.File, { _id: file }) + if (currentFile === undefined) { + throw new Error('file not found') + } + + if (currentFile.file !== version) { + await client.update(currentFile, { file: version }) + } +} diff --git a/server-plugins/drive-resources/src/index.ts b/server-plugins/drive-resources/src/index.ts index ce88aba57b..8e0f6c996a 100644 --- a/server-plugins/drive-resources/src/index.ts +++ b/server-plugins/drive-resources/src/index.ts @@ -25,33 +25,26 @@ import { FindOptions, FindResult } from '@hcengineering/core' -import drive, { type File, type Folder } from '@hcengineering/drive' +import drive, { type FileVersion, type Folder } from '@hcengineering/drive' import type { TriggerControl } from '@hcengineering/server-core' -/** - * @public - */ -export async function OnFileDelete ( +/** @public */ +export async function OnFileVersionDelete ( tx: Tx, { removedMap, ctx, storageAdapter, workspace }: TriggerControl ): Promise { - const rmTx = TxProcessor.extractTx(tx) as TxRemoveDoc + const rmTx = TxProcessor.extractTx(tx) as TxRemoveDoc // Obtain document being deleted. - const attach = removedMap.get(rmTx.objectId) as File - - if (attach === undefined) { - return [] + const version = removedMap.get(rmTx.objectId) as FileVersion + if (version !== undefined) { + await storageAdapter.remove(ctx, workspace, [version.file]) } - await storageAdapter.remove(ctx, workspace, [attach.file]) - return [] } -/** - * @public - */ +/** @public */ export async function FindFolderResources ( doc: Doc, hiearachy: Hierarchy, @@ -71,7 +64,7 @@ export async function FindFolderResources ( // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { - OnFileDelete + OnFileVersionDelete }, function: { FindFolderResources diff --git a/server-plugins/drive/src/index.ts b/server-plugins/drive/src/index.ts index 5e6cb74f40..9afb264fdd 100644 --- a/server-plugins/drive/src/index.ts +++ b/server-plugins/drive/src/index.ts @@ -27,7 +27,7 @@ export const serverDriveId = 'server-drive' as Plugin */ export default plugin(serverDriveId, { trigger: { - OnFileDelete: '' as Resource + OnFileVersionDelete: '' as Resource }, function: { FindFolderResources: '' as Resource