diff --git a/package-lock.json b/package-lock.json index 4a96ff36..db215544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "MIT", "dependencies": { "@heroicons/react": "^1.0.6", - "@intavia/api-client": "^0.1.19", - "@intavia/data-import": "^0.1.4", + "@intavia/api-client": "^0.1.21", + "@intavia/data-import": "^0.1.6", "@intavia/ui": "^0.1.23", "@mapbox/mapbox-gl-draw": "^1.3.0", "@next/bundle-analyzer": "^12.2.5", @@ -297,9 +297,9 @@ "dev": true }, "node_modules/@intavia/api-client": { - "version": "0.1.20", - "resolved": "https://registry.npmjs.org/@intavia/api-client/-/api-client-0.1.20.tgz", - "integrity": "sha512-D7HGumJorVwEA05DTrXSUnAO7TII1PcRdLr2LAxkKSTOd9CPQarV0HW65DXmjGSSeH7HUOp5SJLSYj1E74xJ/Q==", + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@intavia/api-client/-/api-client-0.1.21.tgz", + "integrity": "sha512-jbwC7QrCWHK7obmNHBXdogl7dy8khc39580rSs/8D12SLba5b7tET5kR8o3vya3d6E8pyZUVb4b7ruyLZaSMUw==", "dependencies": { "@stefanprobst/assert": "^1.0.3", "@stefanprobst/request": "^0.2.1", @@ -326,9 +326,9 @@ } }, "node_modules/@intavia/data-import": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@intavia/data-import/-/data-import-0.1.4.tgz", - "integrity": "sha512-Dqeqo02ECYQcaZkioSETbYIhzjVFdIEhpsSY58ngbshIPDXLOjLcabfXpaPv5LnW1RaU8Zq7swEUEB+YphPGqQ==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@intavia/data-import/-/data-import-0.1.6.tgz", + "integrity": "sha512-3MF72L0jkOxIKlbfJMV6q3hiiDlcDGOKN0aCzUjfZHkgMSEO/ctIrfrVXeAzQqfNFCGvt9zIxhTbdRVkXUlRHg==", "dependencies": { "@intavia/api-client": "^0.1.16", "lodash.isequal": "^4.5.0", @@ -10704,9 +10704,9 @@ "dev": true }, "@intavia/api-client": { - "version": "0.1.20", - "resolved": "https://registry.npmjs.org/@intavia/api-client/-/api-client-0.1.20.tgz", - "integrity": "sha512-D7HGumJorVwEA05DTrXSUnAO7TII1PcRdLr2LAxkKSTOd9CPQarV0HW65DXmjGSSeH7HUOp5SJLSYj1E74xJ/Q==", + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@intavia/api-client/-/api-client-0.1.21.tgz", + "integrity": "sha512-jbwC7QrCWHK7obmNHBXdogl7dy8khc39580rSs/8D12SLba5b7tET5kR8o3vya3d6E8pyZUVb4b7ruyLZaSMUw==", "requires": { "@stefanprobst/assert": "^1.0.3", "@stefanprobst/request": "^0.2.1", @@ -10728,9 +10728,9 @@ } }, "@intavia/data-import": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@intavia/data-import/-/data-import-0.1.4.tgz", - "integrity": "sha512-Dqeqo02ECYQcaZkioSETbYIhzjVFdIEhpsSY58ngbshIPDXLOjLcabfXpaPv5LnW1RaU8Zq7swEUEB+YphPGqQ==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@intavia/data-import/-/data-import-0.1.6.tgz", + "integrity": "sha512-3MF72L0jkOxIKlbfJMV6q3hiiDlcDGOKN0aCzUjfZHkgMSEO/ctIrfrVXeAzQqfNFCGvt9zIxhTbdRVkXUlRHg==", "requires": { "@intavia/api-client": "^0.1.16", "lodash.isequal": "^4.5.0", diff --git a/src/app/store/index.ts b/src/app/store/index.ts index d08a11a1..334765be 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -20,6 +20,7 @@ import { service as intaviaApiService } from '@/api/intavia.service'; import errorMiddleware from '@/app/store/error.middleware'; import { slice as intaviaDataSlice } from '@/app/store/intavia.slice'; import { slice as intaviaCollectionsSlice } from '@/app/store/intavia-collections.slice'; +import { slice as intaviaTaggingSlice } from '@/app/store/intavia-tagging.slice'; import { visualizationSlice } from '@/features/common/visualization.slice'; import { contentPaneSlice } from '@/features/storycreator/contentPane.slice'; import { story_api as intaviaStoryApiService } from '@/features/storycreator/story-suite-api.service'; @@ -34,6 +35,7 @@ const persistConfig: PersistConfig = { version: 1, whitelist: [ intaviaCollectionsSlice.name /** Collections. */, + intaviaTaggingSlice.name /** Tagging */, intaviaDataSlice.name /** Entities, events. */, workspacesSlice.name, storyCreatorSlice.name, @@ -47,6 +49,7 @@ const rootReducer = combineReducers({ [intaviaApiService.reducerPath]: intaviaApiService.reducer, [intaviaStoryApiService.reducerPath]: intaviaStoryApiService.reducer, [intaviaCollectionsSlice.name]: intaviaCollectionsSlice.reducer, + [intaviaTaggingSlice.name]: intaviaTaggingSlice.reducer, [intaviaDataSlice.name]: intaviaDataSlice.reducer, [visualQueryingSlice.name]: visualQueryingSlice.reducer, // diff --git a/src/app/store/intavia-tagging.slice.ts b/src/app/store/intavia-tagging.slice.ts new file mode 100644 index 00000000..2d662003 --- /dev/null +++ b/src/app/store/intavia-tagging.slice.ts @@ -0,0 +1,272 @@ +import type { Entity, Event } from '@intavia/api-client'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; +import { assert } from '@stefanprobst/assert'; +import { nanoid } from 'nanoid'; +import { PURGE } from 'redux-persist'; + +import type { RootState } from '@/app/store'; +import { unique } from '@/lib/unique'; + +export interface QueryMetadata { + endpoint: string; + params: Record; +} + +export interface Tag { + id: string; + label: string; + description: string; + color: string; + readonly: boolean; + entities: Array; + events: Array; + metadata: { + queries?: Array; + }; +} + +export function createTag( + payload: OptionalKeys, 'color' | 'entities' | 'events' | 'metadata' | 'readonly'>, +): Tag { + const id = nanoid(); + // if label already exist add "(n+1)" + const tag: Tag = { + entities: [], + events: [], + metadata: {}, + color: '#d8aac2', + readonly: false, + ...payload, + id, + }; + return tag; +} + +interface TaggingState { + tags: { + byId: Record; + }; + reverseMaps: { + entities: Record>; + events: Record>; + }; +} + +const initialState: TaggingState = { + tags: { + byId: {}, + }, + reverseMaps: { + entities: {}, + events: {}, + }, +}; + +export const slice = createSlice({ + name: 'tagging', + initialState, + reducers: { + addTag(state, action: PayloadAction) { + const tag = action.payload; + + //get all labels + const labels = Object.entries(state.tags.byId).map(([_, v]) => { + return v.label; + }); + let newLabel = tag.label; + let count = 0; + while (labels.includes(newLabel)) { + count++; + newLabel = `${tag.label} (${count})`; + } + + state.tags.byId[tag.id] = { ...tag, label: newLabel }; + + for (const entity of tag.entities) { + try { + const reverseMapEntity = state.reverseMaps.entities[entity]; + assert(reverseMapEntity != null); + state.reverseMaps.entities[entity] = unique([...reverseMapEntity, tag.id]); + } catch { + // if assertion does not evaulate: entiy not in reverse map add entry with tag id + state.reverseMaps.entities[entity] = [tag.id]; + } + } + + for (const event of tag.events) { + try { + const reverseMapEvent = state.reverseMaps.events[event]; + assert(reverseMapEvent != null); + state.reverseMaps.events[event] = unique([...reverseMapEvent, tag.id]); + } catch { + // if assertion does not evaulate: entiy not in reverse map add entry with tag id + state.reverseMaps.events[event] = [tag.id]; + } + } + }, + removeTag(state, action: PayloadAction>) { + const tag = action.payload; + const _tag = state.tags.byId[tag.id]; + assert(_tag != null); + if (_tag.readonly) { + return; + } + const entities = _tag.entities; + const events = _tag.events; + + for (const entity of entities) { + const reverseMapEntity = state.reverseMaps.entities[entity]; + assert(reverseMapEntity != null); + const reverseMapEntityFiltered = reverseMapEntity.filter((tagId) => { + return tagId !== tag.id; + }); + if (reverseMapEntityFiltered.length > 0) { + state.reverseMaps.entities[entity] = reverseMapEntityFiltered; + } else { + delete state.reverseMaps.entities[entity]; + } + } + + for (const event of events) { + const reverseMapEvents = state.reverseMaps.events[event]; + assert(reverseMapEvents != null); + const reverseMapEventsFiltered = reverseMapEvents.filter((tagId) => { + return tagId !== tag.id; + }); + if (reverseMapEventsFiltered.length > 0) { + state.reverseMaps.events[event] = reverseMapEventsFiltered; + } else { + delete state.reverseMaps.events[event]; + } + } + + delete state.tags.byId[tag.id]; + }, + tagEntities(state, action: PayloadAction<{ id: Tag['id']; entities: Array }>) { + const { id, entities } = action.payload; + const tag = state.tags.byId[id]; + assert(tag != null); + //add entity ids to tag + tag.entities = unique([...tag.entities, ...entities]); + + //add tag id to reverseMap (entities) + for (const entity of entities) { + try { + const reverseMapEntity = state.reverseMaps.entities[entity]; + assert(reverseMapEntity != null); + state.reverseMaps.entities[entity] = unique([...reverseMapEntity, id]); + } catch { + // if assertion does not evaulate: entiy not in reverse map add entry with tag id + state.reverseMaps.entities[entity] = [id]; + } + } + }, + untagEntities(state, action: PayloadAction<{ id: Tag['id']; entities: Array }>) { + const { id, entities } = action.payload; + const tag = state.tags.byId[id]; + assert(tag != null); + // remove entity id from entity array of tag; + const remove = new Set(entities); + tag.entities = tag.entities.filter((id) => { + return !remove.has(id); + }); + // TODO remove tag id from entity record in reverseMap; remove entity entry if tag list is empty + for (const entity of entities) { + const reverseMapEntity = state.reverseMaps.entities[entity]; + assert(reverseMapEntity != null); + const reverseMapEntityFiltered = reverseMapEntity.filter((tagId) => { + return tagId !== tag.id; + }); + if (reverseMapEntityFiltered.length > 0) { + state.reverseMaps.entities[entity] = reverseMapEntityFiltered; + } else { + delete state.reverseMaps.entities[entity]; + } + } + }, + tagEvents(state, action: PayloadAction<{ id: Tag['id']; events: Array }>) { + const { id, events } = action.payload; + const tag = state.tags.byId[id]; + assert(tag != null); + //add event ids to tag + tag.events = unique([...tag.events, ...events]); + + //add tag id to reverseMap (events) + for (const event of events) { + try { + const reverseMapEvent = state.reverseMaps.events[event]; + assert(reverseMapEvent != null); + state.reverseMaps.events[event] = unique([...reverseMapEvent, id]); + } catch { + // if assertion does not evaulate: entiy not in reverse map add entry with tag id + state.reverseMaps.events[event] = [id]; + } + } + }, + untagEvents(state, action: PayloadAction<{ id: Tag['id']; events: Array }>) { + const { id, events } = action.payload; + const tag = state.tags.byId[id]; + assert(tag != null); + // remove event id from event array of tag; + const remove = new Set(events); + tag.events = tag.events.filter((id) => { + return !remove.has(id); + }); + // TODO remove tag id from event record in reverseMap; remove event entry if tag list is empty + for (const event of events) { + const reverseMapEvents = state.reverseMaps.events[event]; + assert(reverseMapEvents != null); + const reverseMapEventsFiltered = reverseMapEvents.filter((tagId) => { + return tagId !== tag.id; + }); + if (reverseMapEventsFiltered.length > 0) { + state.reverseMaps.events[event] = reverseMapEventsFiltered; + } else { + delete state.reverseMaps.events[event]; + } + } + }, + clear() { + return initialState; + }, + }, + extraReducers(builder) { + builder.addCase(PURGE, () => { + return initialState; + }); + }, +}); + +export const { addTag, removeTag, tagEntities, untagEntities, tagEvents, untagEvents, clear } = + slice.actions; + +export function selectTagging(state: RootState) { + return state.tagging; +} + +export function selectTags(state: RootState) { + return state.tagging.tags; +} + +export function selectTagById(state: RootState, id: Tag['id']) { + return state.tagging.tags.byId[id]; +} + +export function selectTaggedEntities(state: RootState) { + const entities = state.tagging.reverseMaps.entities; + return Object.fromEntries( + Object.entries(entities).filter(([_, v]) => { + return v.length > 0; + }), + ); +} + +export function selectTaggedEvents(state: RootState) { + const events = state.tagging.reverseMaps.events; + return Object.fromEntries( + Object.entries(events).filter(([_, v]) => { + return v.length > 0; + }), + ); +} diff --git a/src/features/data-import/data-import-dialog.tsx b/src/features/data-import/data-import-dialog.tsx index d89a1a7e..d31f0f9b 100644 --- a/src/features/data-import/data-import-dialog.tsx +++ b/src/features/data-import/data-import-dialog.tsx @@ -20,6 +20,7 @@ import { addLocalVocabulary, } from '@/app/store/intavia.slice'; import { addCollection, createCollection } from '@/app/store/intavia-collections.slice'; +import { addTag, createTag } from '@/app/store/intavia-tagging.slice'; import { LoadData } from '@/features/data-import/load-data'; interface DataImportDialogProps { @@ -67,6 +68,14 @@ export function DataImportDialog(props: DataImportDialogProps): JSX.Element { } } + // add tagging + if (data?.tags != null) { + for (const tagCandidate of data.tags) { + const tag = createTag({ readonly: true, ...tagCandidate }); + dispatch(addTag(tag)); + } + } + toast({ title: 'Success', description: 'Data was imported successfully.', diff --git a/src/pages/tagging.page.tsx b/src/pages/tagging.page.tsx new file mode 100644 index 00000000..0b57ac41 --- /dev/null +++ b/src/pages/tagging.page.tsx @@ -0,0 +1,230 @@ +import { PencilIcon, TrashIcon, XIcon } from '@heroicons/react/outline'; +import { PlusIcon } from '@heroicons/react/solid'; +import type { Entity, Event } from '@intavia/api-client'; +import { IconButton, Label } from '@intavia/ui'; +import { assert } from '@stefanprobst/assert'; +import { Fragment, useId } from 'react'; + +import { withDictionaries } from '@/app/i18n/with-dictionaries'; +import { useAppDispatch, useAppSelector } from '@/app/store'; +import { selectVocabularyEntries } from '@/app/store/intavia.slice'; +import type { Tag } from '@/app/store/intavia-tagging.slice'; +import { + addTag, + createTag, + removeTag, + selectTaggedEntities, + selectTaggedEvents, + selectTags, + tagEntities, + tagEvents, + untagEntities, + untagEvents, +} from '@/app/store/intavia-tagging.slice'; +import { useEntities } from '@/lib/use-entities'; +import { useEvents } from '@/lib/use-events'; + +export const getStaticProps = withDictionaries(['common']); + +export default function TaggingPage(): JSX.Element { + const dispatch = useAppDispatch(); + + const tags = useAppSelector(selectTags); + const taggedEntities = useAppSelector(selectTaggedEntities); + const taggedEvents = useAppSelector(selectTaggedEvents); + const vocabularies = useAppSelector(selectVocabularyEntries); + const _entities = useEntities(Object.keys(taggedEntities)).data; + const _events = useEvents(Object.keys(taggedEvents)).data; + + const id = useId(); + + const entitiesPool = [ + 'aHR0cDovL3d3dy5pbnRhdmlhLmV1L2FwaXMvcGVyc29ucHJveHkvNzA2ODI=', + 'aHR0cDovL3d3dy53aWtpZGF0YS5vcmcvZW50aXR5L1ExMDc2Nzc2ODY=', + 'aHR0cDovL3d3dy5pbnRhdmlhLmV1L2FwaXMvcGVyc29ucHJveHkvNzA2Nzk=', + ]; + + const eventsPool = [ + 'aHR0cHM6Ly93d3cuaW50YXZpYS5ldS9wcm9kdWN0aW9uX2V2ZW50L1ExOTkxMjgyOA==', + 'aHR0cHM6Ly93d3cuaW50YXZpYS5ldS9wcm9kdWN0aW9uX2V2ZW50L1ExOTkxMjgwNw==', + 'aHR0cHM6Ly93d3cuaW50YXZpYS5ldS9wcm9kdWN0aW9uX2V2ZW50L1E1ODQ0MjYyMQ==', + 'aHR0cHM6Ly93d3cuaW50YXZpYS5ldS9wcm9kdWN0aW9uX2V2ZW50L1EyNTUxNDE3', + 'aHR0cHM6Ly93d3cuaW50YXZpYS5ldS9wcm9kdWN0aW9uX2V2ZW50L1EyNjI1ODA3MQ==', + ]; + + const labelPool = ['Klimt', 'Gustav', 'Reisen', 'Kunstwerke']; + + const colorPool = ['#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3']; + + function onAddTag() { + const tag = createTag({ + label: labelPool[Math.floor(Math.random() * labelPool.length)]!, + description: 'Klimt Klimt Klimt', + color: colorPool[Math.floor(Math.random() * labelPool.length)]!, + entities: entitiesPool + .sort(() => { + return 0.5 - Math.random(); + }) + .slice(0, 1), + // let random = array.sort(() => .5 - Math.random()).slice(0,n) + events: eventsPool + .sort(() => { + return 0.5 - Math.random(); + }) + .slice(0, 2), + }); + + dispatch(addTag(tag)); + + dispatch( + tagEntities({ + id: tag.id, + entities: entitiesPool + .sort(() => { + return 0.5 - Math.random(); + }) + .slice(0, 1), + }), + ); + + dispatch( + tagEvents({ + id: tag.id, + events: eventsPool + .sort(() => { + return 0.5 - Math.random(); + }) + .slice(0, 1), + }), + ); + } + + function onDeleteTag(tagId: Tag['id']) { + dispatch(removeTag({ id: tagId })); + } + + function onUntagEntities(tagId: Tag['id'], entityIds: Array) { + dispatch(untagEntities({ id: tagId, entities: entityIds })); + } + + function onUntagEvents(tagId: Tag['id'], eventIds: Array) { + dispatch(untagEvents({ id: tagId, events: eventIds })); + } + + return ( + +
+
+

All Tags

+ + + + + + {Object.entries(tags.byId).map(([key, tag]) => { + return ( +
+ + + + + { + onDeleteTag(tag.id); + }} + variant="destructive" + > + + +
+ ); + })} +
+
+

Tagged Entities

+ {Array.from(_entities!.values()).map((entity) => { + const entityTags = taggedEntities[entity.id]; + return ( +
+ + {entityTags!.map((tagId) => { + const tag = tags.byId[tagId]; + assert(tag != null); + return ( +
+ + { + onUntagEntities(tag.id, [entity.id]); + }} + variant="destructive" + > + + +
+ ); + })} +
+ ); + })} +
+
+

Tagged Entities

+ {Array.from(_events!.values()).map((event) => { + const eventTags = taggedEvents[event.id]; + return ( +
+ + + {eventTags!.map((tagId) => { + const tag = tags.byId[tagId]; + assert(tag != null); + return ( +
+ + { + onUntagEvents(tag.id, [event.id]); + }} + variant="destructive" + > + + +
+ ); + })} +
+ ); + })} +
+
+
+ ); +}