From 9a360d152f7e6fc27031eef2fb371fa6a22293f3 Mon Sep 17 00:00:00 2001 From: Ben Merckx Date: Wed, 21 Feb 2024 13:52:03 +0100 Subject: [PATCH] Remove media files if a media library is removed --- src/backend/Database.test.ts | 22 ++- src/backend/Database.ts | 19 +-- src/backend/Handler.ts | 8 +- src/backend/data/ChangeSet.ts | 57 ++++--- src/backend/data/ChangeSet.ts_ | 187 ----------------------- src/backend/resolver/EntryResolver.ts | 10 +- src/backend/test/Example.ts | 22 ++- src/core/Graph.ts | 4 +- src/core/Transaction.ts | 1 + src/dashboard/atoms/EntryEditorAtoms.ts | 26 ++++ src/dashboard/view/MediaExplorer.tsx | 4 +- src/dashboard/view/entry/EntryHeader.tsx | 19 ++- 12 files changed, 140 insertions(+), 239 deletions(-) delete mode 100644 src/backend/data/ChangeSet.ts_ diff --git a/src/backend/Database.test.ts b/src/backend/Database.test.ts index 9f7c3aef1..8df83831a 100644 --- a/src/backend/Database.test.ts +++ b/src/backend/Database.test.ts @@ -28,7 +28,7 @@ test('remove child entries', async () => { assert.ok(res1) assert.is(res1.parent, sub.entryId) await example.commit(Edit.remove(parent.entryId)) - const res2 = await example.get(Query.whereId(entry.entryId)) + const res2 = await example.maybeGet(Query.whereId(entry.entryId)) assert.not.ok(res2) }) @@ -155,4 +155,24 @@ test('field creators', async () => { }) }) +test('remove media library and files', async () => { + const example = createExample() + const {MediaLibrary, MediaFile} = example.schema + const library = Edit.create(MediaLibrary) + .setWorkspace('main') + .setRoot('media') + await example.commit(library) + const upload = Edit.upload([ + 'test.txt', + new TextEncoder().encode('Hello, World!') + ]).setParent(library.entryId) + await example.commit(upload) + const result = await example.get(Query.whereId(upload.entryId)) + assert.is(result.parent, library.entryId) + assert.is(result.root, 'media') + await example.commit(Edit.remove(library.entryId)) + const result2 = await example.maybeGet(Query.whereId(upload.entryId)) + assert.not.ok(result2) +}) + test.run() diff --git a/src/backend/Database.ts b/src/backend/Database.ts index 56c303e17..2491c41d7 100644 --- a/src/backend/Database.ts +++ b/src/backend/Database.ts @@ -24,7 +24,7 @@ import {Media} from './Media.js' import {Source} from './Source.js' import {Store} from './Store.js' import {Target} from './Target.js' -import {ChangeSetCreator} from './data/ChangeSet.js' +import {Change, ChangeType} from './data/ChangeSet.js' import {AlineaMeta} from './db/AlineaMeta.js' import {createEntrySearch} from './db/CreateEntrySearch.js' import {JsonLoader} from './loader/JsonLoader.js' @@ -711,24 +711,21 @@ export class Database implements Syncable { }) if (target && publishSeed.length > 0) { - const changeSetCreator = new ChangeSetCreator(this.config) - const mutations = publishSeed.map((seed): Mutation => { + const changes = publishSeed.map((seed): Change => { const workspace = this.config.workspaces[seed.workspace] const file = paths.join( Workspace.data(workspace).source, seed.root, seed.filePath ) - return { - type: MutationType.Create, - entryId: seed.entryId, - file: file, - entry: seed - } + const record = createRecord(seed) + const contents = new TextDecoder().decode( + JsonLoader.format(this.config.schema, record) + ) + return {type: ChangeType.Write, file, contents} }) - const changes = changeSetCreator.create(mutations) await target.mutate( - {commitHash: '', mutations: changes}, + {commitHash: '', mutations: [{changes, meta: undefined!}]}, {logger: new Logger('seed')} ) } diff --git a/src/backend/Handler.ts b/src/backend/Handler.ts index 3986094fc..670e136b0 100644 --- a/src/backend/Handler.ts +++ b/src/backend/Handler.ts @@ -8,6 +8,7 @@ import {Draft} from 'alinea/core/Draft' import {Entry} from 'alinea/core/Entry' import {EntryRecord} from 'alinea/core/EntryRecord' import {EntryPhase, EntryRow} from 'alinea/core/EntryRow' +import {Graph} from 'alinea/core/Graph' import {EditMutation, Mutation, MutationType} from 'alinea/core/Mutation' import {PreviewUpdate, ResolveRequest, Resolver} from 'alinea/core/Resolver' import {createSelection} from 'alinea/core/pages/CreateSelection' @@ -60,7 +61,10 @@ export class Handler implements Resolver { options.config.schema, this.parsePreview.bind(this) ) - this.changes = new ChangeSetCreator(options.config) + this.changes = new ChangeSetCreator( + options.config, + new Graph(options.config, this) + ) const auth = options.auth ?? Auth.anonymous() this.connect = ctx => new HandlerConnection(this, ctx) this.router = createRouter(auth, this.connect) @@ -153,7 +157,7 @@ class HandlerConnection implements Connection { ): Promise<{commitHash: string}> { const {target, db} = this.handler.options if (!target) throw new Error('Target not available') - const changeSet = this.handler.changes.create(mutations) + const changeSet = await this.handler.changes.create(mutations) const {commitHash: fromCommitHash} = await this.handler.syncPending() let toCommitHash: string try { diff --git a/src/backend/data/ChangeSet.ts b/src/backend/data/ChangeSet.ts index 6a568f790..94228ef57 100644 --- a/src/backend/data/ChangeSet.ts +++ b/src/backend/data/ChangeSet.ts @@ -1,6 +1,7 @@ import {Config} from 'alinea/core/Config' import {META_KEY, createRecord} from 'alinea/core/EntryRecord' import {EntryPhase} from 'alinea/core/EntryRow' +import {Graph} from 'alinea/core/Graph' import { ArchiveMutation, CreateMutation, @@ -16,8 +17,10 @@ import { RemoveEntryMutation, UploadMutation } from 'alinea/core/Mutation' -import {EntryUrlMeta, Type} from 'alinea/core/Type' +import {Query} from 'alinea/core/Query' +import {Type} from 'alinea/core/Type' import {Workspace} from 'alinea/core/Workspace' +import {MediaFile} from 'alinea/core/media/MediaTypes' import {join} from 'alinea/core/util/Paths' import {JsonLoader} from '../loader/JsonLoader.js' @@ -68,22 +71,7 @@ const decoder = new TextDecoder() const loader = JsonLoader export class ChangeSetCreator { - constructor(public config: Config) {} - - entryLocation( - {locale, parentPaths, path, phase}: EntryUrlMeta, - extension: string - ) { - const segments = (locale ? [locale] : []) - .concat( - parentPaths - .concat(path) - .map(segment => (segment === '' ? 'index' : segment)) - ) - .join('/') - const phaseSegment = phase === EntryPhase.Published ? '' : `.${phase}` - return (segments + phaseSegment + extension).toLowerCase() - } + constructor(protected config: Config, protected graph: Graph) {} editChanges({previousFile, file, entry}: EditMutation): Array { const type = this.config.schema[entry.type] @@ -160,10 +148,32 @@ export class ChangeSetCreator { ] } - removeChanges({file}: RemoveEntryMutation): Array { + async removeChanges({ + entryId, + file + }: RemoveEntryMutation): Promise> { if (!file.endsWith(`.${EntryPhase.Archived}.json`)) return [] + const {workspace, files} = await this.graph.preferPublished.get( + Query.whereId(entryId).select({ + workspace: Query.workspace, + files: Query.children(MediaFile, 999) + }) + ) + const mediaDir = + Workspace.data(this.config.workspaces[workspace])?.mediaDir ?? '' + const removeFiles: Array = files.map(file => { + const binaryLocation = join(mediaDir, file.location) + return { + type: ChangeType.Delete, + file: binaryLocation + } + }) return [ + // Remove any media files in this location + ...removeFiles, + // Remove entry {type: ChangeType.Delete, file}, + // Remove children { type: ChangeType.Delete, file: file.slice(0, -`.${EntryPhase.Archived}.json`.length) @@ -224,7 +234,7 @@ export class ChangeSetCreator { return [{type: ChangeType.Delete, file: mutation.file}, removeBinary] } - mutationChanges(mutation: Mutation): Array { + async mutationChanges(mutation: Mutation): Promise> { switch (mutation.type) { case MutationType.Edit: return this.editChanges(mutation) @@ -251,9 +261,10 @@ export class ChangeSetCreator { } } - create(mutations: Array): ChangeSet { - return mutations.map(meta => { - return {changes: this.mutationChanges(meta), meta} - }) + async create(mutations: Array): Promise { + const res = [] + for (const meta of mutations) + res.push({changes: await this.mutationChanges(meta), meta}) + return res } } diff --git a/src/backend/data/ChangeSet.ts_ b/src/backend/data/ChangeSet.ts_ deleted file mode 100644 index 261780dd0..000000000 --- a/src/backend/data/ChangeSet.ts_ +++ /dev/null @@ -1,187 +0,0 @@ -import {EntryPhase, EntryRow, EntryUrlMeta, Type, Workspace} from 'alinea/core' -import {Entry} from 'alinea/core/Entry' -import {createRecord} from 'alinea/core/EntryRecord' -import {Realm} from 'alinea/core/pages/Realm' -import {join} from 'alinea/core/util/Paths' -import {Database} from '../Database.js' -import {JsonLoader} from '../loader/JsonLoader.js' - -export interface ChangeSet { - write: Array<{id: string; file: string; contents: string}> - rename: Array<{id: string; file: string; to: string}> - delete: Array<{id: string; file: string}> -} - -const decoder = new TextDecoder() -const loader = JsonLoader - -export namespace ChangeSet { - export function entryLocation( - {locale, parentPaths, path, phase}: EntryUrlMeta, - extension: string - ) { - const segments = (locale ? [locale] : []) - .concat( - parentPaths - .concat(path) - .map(segment => (segment === '' ? 'index' : segment)) - ) - .join('/') - const phaseSegment = phase === EntryPhase.Published ? '' : `.${phase}` - return (segments + phaseSegment + extension).toLowerCase() - } - - export async function create( - db: Database, - entries: Array, - phase: EntryPhase, - canRename = true - ): Promise { - const changes: ChangeSet = { - write: [], - rename: [], - delete: [] - } - for (const entry of entries) { - const type = db.config.schema[entry.type] - if (!type) { - console.warn(`Cannot publish entry of unknown type: ${entry.type}`) - continue - } - const parentData = - entry.parent && - (await db.find( - Entry({entryId: entry.parent}) - .select({ - path: Entry.path, - paths({parents}) { - return parents().select(Entry.path) - } - }) - .first(), - Realm.PreferPublished - )) - if (entry.parent && !parentData) - throw new Error(`Cannot find parent entry: ${entry.parent}`) - const parentPaths = parentData - ? parentData.paths.concat(parentData.path) - : [] - const workspace = db.config.workspaces[entry.workspace] - const isContainer = Type.isContainer(type) - const isPublishing = phase === EntryPhase.Published - const {source: contentDir} = Workspace.data(workspace) - const entryMeta = { - phase, - path: entry.path, - parentPaths, - locale: entry.locale ?? undefined - } - const location = entryLocation(entryMeta, loader.extension) - function abs(root: string, file: string) { - return join(contentDir, root, file) - } - const file = abs(entry.root, location) - const record = createRecord(entry) - changes.write.push({ - id: entry.entryId, - file, - contents: decoder.decode(loader.format(db.config.schema, record)) - }) - const previousPhase: Realm = entry.phase as any - const previous = await db.find( - Entry({entryId: entry.entryId}) - .select({ - phase: Entry.phase, - path: Entry.path, - locale: Entry.locale, - root: Entry.root - }) - .maybeFirst(), - previousPhase - ) - - // Cleanup old files - if (previous && phase !== EntryPhase.Draft) { - const previousMeta: EntryUrlMeta = {...previous, parentPaths} - const oldLocation = entryLocation(previousMeta, loader.extension) - if (oldLocation !== location) { - const oldFile = abs(previous.root, oldLocation) - changes.delete.push({id: entry.entryId, file: oldFile}) - if (isPublishing && isContainer) { - if (canRename) { - const oldFolder = abs( - previous.root, - entryLocation(previousMeta, '') - ) - const newFolder = abs(previous.root, entryLocation(entryMeta, '')) - changes.rename.push({ - id: entry.entryId, - file: oldFolder, - to: newFolder - }) - } else { - await renameChildren( - entryMeta.parentPaths.concat(entryMeta.path), - entry.entryId - ) - changes.delete.push({ - id: entry.entryId, - file: abs(previous.root, entryLocation(previousMeta, '')) - }) - } - } - } - } - - async function renameChildren(newPaths: Array, parentId: string) { - // List every child as write + delete - const children = await db.find( - Entry() - .where(Entry.parent.is(parentId)) - .select({ - child: {...Entry}, - oldPaths({parents}) { - return parents().select(Entry.path) - } - }), - Realm.All - ) - for (const {child, oldPaths} of children) { - const childFile = abs( - child.root, - entryLocation( - { - phase: child.phase, - path: child.path, - parentPaths: oldPaths, - locale: child.locale - }, - loader.extension - ) - ) - changes.delete.push({id: child.entryId, file: childFile}) - const newLocation = abs( - entry.root, - entryLocation( - { - phase: child.phase, - path: child.path, - parentPaths: newPaths, - locale: child.locale - }, - loader.extension - ) - ) - const record = createRecord(child) - changes.write.push({ - id: child.entryId, - file: newLocation, - contents: decoder.decode(loader.format(db.config.schema, record)) - }) - renameChildren(newPaths.concat(child.path), child.entryId) - } - } - } - return changes - } -} diff --git a/src/backend/resolver/EntryResolver.ts b/src/backend/resolver/EntryResolver.ts index 208e6de37..85b285932 100644 --- a/src/backend/resolver/EntryResolver.ts +++ b/src/backend/resolver/EntryResolver.ts @@ -658,9 +658,11 @@ export class EntryResolver { // console.warn('Could not decode preview update', err) } } - const result = await this.db.store(query) - const linkResolver = new LinkResolver(this, this.db.store, ctx.realm) - if (result) await this.post({linkResolver}, result, selection) - return result + return this.db.store.transaction(async tx => { + const result = await tx(query) + const linkResolver = new LinkResolver(this, tx, ctx.realm) + if (result) await this.post({linkResolver}, result, selection) + return result + }) } } diff --git a/src/backend/test/Example.ts b/src/backend/test/Example.ts index a729a8139..bdc68122e 100644 --- a/src/backend/test/Example.ts +++ b/src/backend/test/Example.ts @@ -33,11 +33,15 @@ export function createExample() { path: path('Path'), ...tabs( tab('Tab 1', { - name: path('Name') + fields: { + name: path('Name') + } }), tab('Tab 2', { - name: text('Name'), - name2: text('Name') + fields: { + name: text('Name'), + name2: text('Name') + } }) ), [type.meta]: { @@ -66,8 +70,10 @@ export function createExample() { }), richText: richText('Rich text field'), select: select('Select field', { - a: 'Option a', - b: 'Option b' + options: { + a: 'Option a', + b: 'Option b' + } }), number: number('Number field', { minValue: 0, @@ -94,8 +100,10 @@ export function createExample() { }), multipleWithFields: link.multiple('Multiple With extra fields', { fields: type({ - fieldA: text('Field A', {width: 0.5}), - fieldB: text('Field B', {width: 0.5, required: true}) + fields: { + fieldA: text('Field A', {width: 0.5}), + fieldB: text('Field B', {width: 0.5, required: true}) + } }) }), list: list('My list field', { diff --git a/src/core/Graph.ts b/src/core/Graph.ts index 5d8798814..6120f7d55 100644 --- a/src/core/Graph.ts +++ b/src/core/Graph.ts @@ -111,8 +111,8 @@ export class GraphRealm implements GraphRealmApi { get(select: S): Promise> async get(select: any) { - const result = this.maybeGet(select) - if (result === null) throw new Error('Not found') + const result = await this.maybeGet(select) + if (result === null) throw new Error('Entry not found') return result } diff --git a/src/core/Transaction.ts b/src/core/Transaction.ts index 7a739ca7e..875d730b3 100644 --- a/src/core/Transaction.ts +++ b/src/core/Transaction.ts @@ -271,6 +271,7 @@ export class EditOperation extends Operation { } export class CreateOperation extends Operation { + /** @internal */ entry: Partial private entryRow = async (cms: CMS) => { const partial = this.entry diff --git a/src/dashboard/atoms/EntryEditorAtoms.ts b/src/dashboard/atoms/EntryEditorAtoms.ts index 13fa26cf8..4d7d424f5 100644 --- a/src/dashboard/atoms/EntryEditorAtoms.ts +++ b/src/dashboard/atoms/EntryEditorAtoms.ts @@ -511,6 +511,31 @@ export function createEntryEditor(entryData: EntryData) { }) }) + const deleteMediaLibrary = atom(null, (get, set) => { + const result = confirm( + 'Are you sure you want to delete this folder and all the files in?' + ) + if (!result) return + const published = entryData.phases[EntryPhase.Published] + const mutations: Array = [ + { + type: MutationType.Archive, + entryId: published.entryId, + file: entryFile(published) + }, + { + type: MutationType.Remove, + entryId: published.entryId, + file: entryFile({...published, phase: EntryPhase.Archived}) + } + ] + return set(transact, { + transition: EntryTransition.DeleteArchived, + action: () => set(mutateAtom, mutations), + errorMessage: 'Could not complete delete action, please try again later' + }) + }) + const deleteFile = atom(null, (get, set) => { // Prompt for confirmation const result = confirm('Are you sure you want to delete this file?') @@ -684,6 +709,7 @@ export function createEntryEditor(entryData: EntryData) { archivePublished, publishArchived, deleteFile, + deleteMediaLibrary, deleteArchived, saveTranslation, discardEdits, diff --git a/src/dashboard/view/MediaExplorer.tsx b/src/dashboard/view/MediaExplorer.tsx index aa1a4805c..edf18d9d8 100644 --- a/src/dashboard/view/MediaExplorer.tsx +++ b/src/dashboard/view/MediaExplorer.tsx @@ -7,6 +7,7 @@ import {useQuery} from 'react-query' import {Entry} from 'alinea/core/Entry' import {RootData} from 'alinea/core/Root' import {workspaceMediaDir} from 'alinea/core/util/EntryFilenames' +import {EntryHeader} from 'alinea/dashboard/view/entry/EntryHeader' import {IcRoundArrowBack} from 'alinea/ui/icons/IcRoundArrowBack' import {useAtomValue} from 'jotai' import {graphAtom} from '../atoms/DbAtoms.js' @@ -49,7 +50,7 @@ export function MediaExplorer({editor}: MediaExplorerProps) { const cursor = Entry() .where(condition) .orderBy(Entry.type.desc(), Entry.entryId.desc()) - const info = await graph.preferDraft.get( + const info = await graph.preferDraft.maybeGet( Entry({entryId: parentId}) .select({ title: Entry.title, @@ -73,6 +74,7 @@ export function MediaExplorer({editor}: MediaExplorerProps) { return ( <>
+ {editor && }
diff --git a/src/dashboard/view/entry/EntryHeader.tsx b/src/dashboard/view/entry/EntryHeader.tsx index fb3a4bbf6..7efd8b60a 100644 --- a/src/dashboard/view/entry/EntryHeader.tsx +++ b/src/dashboard/view/entry/EntryHeader.tsx @@ -79,6 +79,7 @@ export function EntryHeader({editor, editable = true}: EntryHeaderProps) { const previewRevision = useAtomValue(editor.previewRevision) const isActivePhase = editor.activePhase === selectedPhase const isMediaFile = editor.activeVersion.type === 'MediaFile' + const isMediaLibrary = editor.activeVersion.type === 'MediaLibrary' const hasChanges = useAtomValue(editor.hasChanges) const currentTransition = useAtomValue(editor.transition)?.transition const untranslated = locale && locale !== editor.activeVersion.locale @@ -100,6 +101,7 @@ export function EntryHeader({editor, editable = true}: EntryHeaderProps) { const publishArchived = useSetAtom(editor.publishArchived) const deleteArchived = useSetAtom(editor.deleteArchived) const deleteFile = useSetAtom(editor.deleteFile) + const deleteMediaLibrary = useSetAtom(editor.deleteMediaLibrary) const queryClient = useQueryClient() function deleteFileAndNavigate() { return deleteFile()?.then(() => { @@ -107,6 +109,12 @@ export function EntryHeader({editor, editable = true}: EntryHeaderProps) { navigate(nav.root(entryLocation)) }) } + function deleteMediaLibraryAndNavigate() { + return deleteMediaLibrary()?.then(() => { + queryClient.invalidateQueries('explorer') + navigate(nav.root(entryLocation)) + }) + } const saveTranslation = useSetAtom(editor.saveTranslation) const discardEdits = useSetAtom(editor.discardEdits) const translate = () => saveTranslation(locale!) @@ -164,6 +172,15 @@ export function EntryHeader({editor, editable = true}: EntryHeaderProps) { Delete + ) : isMediaLibrary ? ( + <> + + Delete + + ) : ( - {!isMediaFile && ( + {!isMediaFile && !isMediaLibrary && ( setShowHistory(!showHistory)} >