diff --git a/packages/app/package.json b/packages/app/package.json index 2513230..e3b2e6b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,8 +11,8 @@ "author": "", "license": "MIT", "dependencies": { - "@archipel/ui": "^1.0", "@archipel/common": "^0.1", + "@archipel/ui": "^1.0", "filereader-stream": "^2.0.0", "pretty-bytes": "^5.1.0", "proptypes": "^1.1.0", @@ -21,6 +21,7 @@ "react-icons": "^3.2.2", "react-toggle-button": "^2.2.0", "react-values": "^0.3.0", + "simpl-schema": "^1.5.5", "speedometer": "^1.1.0", "through2": "^2.0.3", "wayfarer": "^6.6.4" diff --git a/packages/app/src/features/archive/netStatsStore.js b/packages/app/src/features/archive/netStatsStore.js index a01c763..c41b58d 100644 --- a/packages/app/src/features/archive/netStatsStore.js +++ b/packages/app/src/features/archive/netStatsStore.js @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { getApi } from '../../lib/api' +import deepEqual from '@archipel/common/util/deepEqual' let stats = {} const subscribers = new Set() @@ -66,17 +67,3 @@ export function useArchiveStats (archive) { }, [archive]) return state } - -function deepEqual (a, b) { - // if (a !== b) return false // unequal by reference - - // according to http://www.mattzeunert.com/2016/01/28/javascript-deep-equal.html - // the following is faster than module deep-equal https://github.com/substack/node-deep-equal/ - // so subject to high quality optimization - let sa = JSON.stringify(a) - let sb = JSON.stringify(b) - - if (sa !== sb) return false - - return true -} diff --git a/packages/app/src/features/metadata/EditSubmetadataOverlay.js b/packages/app/src/features/metadata/EditSubmetadataOverlay.js new file mode 100644 index 0000000..f97f842 --- /dev/null +++ b/packages/app/src/features/metadata/EditSubmetadataOverlay.js @@ -0,0 +1,28 @@ +import React from 'react' +import { MetadataEditor } from './MetadataEditor' +import { Modal } from '@archipel/ui' + +export function EditMetadataOverlay (props) { + const { archive, type, literal } = props + + /* + TODO: find good UID for arbitrary resources + include discovery key? maybe not since, + Max Mustermann is Max Mustermann with respect + to arbitrary archives. + include literal name, such as 'Max Mustermann' or 'Kiel' + yes, but there are several Max Mustermanns and Kiel's around + include type? yes, at least provides more context, + but does not resolve above ambiguity + */ + const ID = archive + type + literal + console.log('Submeta called with', props) + + return ( + <> + + + + + ) +} \ No newline at end of file diff --git a/packages/app/src/features/metadata/MemorizingTripleStore.js b/packages/app/src/features/metadata/MemorizingTripleStore.js new file mode 100644 index 0000000..affdf9e --- /dev/null +++ b/packages/app/src/features/metadata/MemorizingTripleStore.js @@ -0,0 +1,58 @@ +import { getApi } from '../../lib/api' +import deepEqual from '@archipel/common/util/deepEqual' + +export default function MemorizingTripleStore () { + this.store = [['queries'], ['results']] + this.tripleStore = null + this.init() + this.activateCleaner() +} + +MemorizingTripleStore.prototype.init = async function () { + this.tripleStore = await getApi().then((apis) => apis.hypergraph) +} + +MemorizingTripleStore.prototype.activateCleaner = function (interval, cleaningSize) { + if (!interval) interval = 300000 + if (!cleaningSize) cleaningSize = 100 + let reductionSize = Math.floor(cleaningSize * 3 / 4) + + this.cleaningInterval = setInterval(() => { + let length = this.store.length + if (length >= cleaningSize) { + this.store[0] = this.store[0].slice(length - reductionSize) + this.store[1] = this.store[1].slice(length - reductionSize) + } + }, interval) +} + +MemorizingTripleStore.prototype.clearCleaner = function () { + clearInterval(this.cleaningInterval) +} + +MemorizingTripleStore.prototype.storeQuery = function (query, result) { + this.store[0].push(query) + this.store[1].push(result) +} + +MemorizingTripleStore.prototype.restoreQuery = function (query) { + let index = this.store[0].findIndex((elem) => deepEqual(elem, query)) + if (index >= 1) return this.store[1][index] + return null +} + +MemorizingTripleStore.prototype.get = async function (archive, query, opts) { + let result = this.restoreQuery({ archive, query }) + if (result) return result + result = await this.tripleStore.get(archive, query, opts) + this.storeQuery({ archive, query }, result) + return result +} + +MemorizingTripleStore.prototype.searchSubjects = async function (archive, query, opts) { + let result = this.restoreQuery({ archive, query }) + if (result) return result + result = await this.tripleStore.searchSubjects(archive, query, opts) + this.storeQuery({ archive, query }, result) + return result +} diff --git a/packages/app/src/features/metadata/MetadataEditor.js b/packages/app/src/features/metadata/MetadataEditor.js new file mode 100644 index 0000000..68c7bfa --- /dev/null +++ b/packages/app/src/features/metadata/MetadataEditor.js @@ -0,0 +1,249 @@ +/* +Component to allow viewing and editing of the metadat associated with a file. +Indendet to be used in a Sidebar next + - to a file + - to a folder, to allow mass editing of childs metadata by directory structure. +*/ +import React, { useEffect, useState } from 'react' +import { MdExpandMore, MdExpandLess, MdAdd, MdClear, MdKeyboardReturn } from 'react-icons/md' +import { Button, TightInputForm, DeleteIcon } from '@archipel/ui' +import MetadataLink from './MetadataLink' +import { EditorController } from './editorController' +import { makeLink } from '@archipel/common/util/triples' +import { useMetadata } from './editorStore' +import { getArchive } from '../archive/archive' +import { Categories } from './schemas' +import { EditMetadataOverlay } from './EditSubmetadataOverlay' + +/* +Category +*/ + +function ShowAndSetCategory (props) { + const { controller } = props + const [category, setCategory] = useState(controller.category()) + const [expanded, setExpand] = useState(false) + + const ExpandIcon = expanded ? MdExpandLess : MdExpandMore + return ( +
+
+ Category: + {Categories.getLabel(category)} +
+ +
+
+ {expanded && } +
+ ) + + async function asyncSetCategory (category) { + await controller.setCategory(category) + setCategory(controller.category()) + } + + function onExpand (e) { + e.stopPropagation() + setExpand(state => !state) + } +} + +/* +Metadata List +*/ + +export function ListAndEditMetadata (props) { + const { metadata } = props + const { ofCategory: category, ...rest } = metadata + + let keyIndex = 0 + if (typeof rest !== 'object') return rest + + return ( + + ) +} + +function MetadataListEntry (props) { + const { metadataEntry, setDraftValue } = props + let { values } = metadataEntry + + return ( +
  • + {`${metadataEntry.label}:`} + { values && + + } + { setDraftValue && + } +
  • + ) +} + +function MetadataListEntryItem (props) { + let { value, setDeleteValue, entryKey, metadataEntry } = props + let { type: valueType } = metadataEntry + + let Submeta = null + if (valueType) { + for (let key of Object.keys(valueType.definitions)) { + if (valueType.definitions[key].type && valueType.definitions[key].type._schema) { + Submeta = + } + } + } + + if (value.state === 'actual') { + return
  • + {Submeta || value.value} + +
  • + } + if (value.state === 'delete') { + return
  • + {value.value} +
  • + } + if (value.state === 'draft') { + return
  • + {value.value} + +
  • + } + return {JSON.stringify(value)} + + function DeleteButton (props) { + // return + return setDeleteValue(entryKey, value.value)} /> + } +} + +function InputMetadataEntry (props) { + let { entryKey, metadataEntry } = props + let { singleType, type: valueType } = metadataEntry + + if (valueType) { + valueType = valueType.definitions[0].type.name ? valueType.definitions[0].type.name.toLowerCase() : 'string' + } else { + valueType = 'string' + } + + let [draftValue, setDraftValue] = useState(metadataEntry.toBeValue) + useEffect(() => { + setDraftValue(props.draftValue) + }, [props]) + + const handleKeyPress = function (e) { + if (e.keyCode === 27) setDraftValue('') + } + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress.bind(this), false) + return document.removeEventListener('keydown', handleKeyPress.bind(this), false) + }, []) + + return ( + setDraftValue(e.target.value)} + value={draftValue || ''} + onSubmit={() => props.setDraftValue(entryKey, draftValue)} + buttonSize={20} + addForm={!singleType} /> + //
    + // setDraftValue(e.target.value)} + // value={draftValue || ''} /> + // + //
    + ) +} + +/* +Parent +*/ + +let controller = null + +export function MetadataEditor (props) { + console.log('ME called with', props) + let { ID } = props + + useEffect(() => { + controller = new EditorController({ ...props }) + + // Feature or Anti-Feature?: + return () => controller.writeChanges({ onUnmount: true }) + }, []) + + const metadata = useMetadata(ID) + + if (isObjectEmpty(metadata)) return loading... + + return ( +
    +
    + +
    +
    + {} +
    + +
    + ) +} + +export function FileMetadataEditor (props) { + if (props.stat.isDirectory) return null + let archive = getArchive(props.archive) + + if (!archive || !archive.structures) return null + let ID = makeLink(archive.structures[0].discoveryKey, props.path) + + return +} + +function isObjectEmpty (object) { + if (typeof object !== 'object') return false + if (Object.keys(object).length === 0) return true + return false +} diff --git a/packages/app/src/features/metadata/MetadataHub.js b/packages/app/src/features/metadata/MetadataHub.js new file mode 100644 index 0000000..3e7cd4a --- /dev/null +++ b/packages/app/src/features/metadata/MetadataHub.js @@ -0,0 +1,240 @@ +'use strict' +/* +Browse by metadata. +e.g. show all files with artist=freddy mercury or something. +*/ +import React, { useEffect, useState, useReducer } from 'react' +import hubController from './hubController' +import { ListAndEditMetadata } from './MetadataEditor' +import { getAllKeysAndLabels, Categories } from './schemas' +import { MdExpandLess, MdExpandMore } from 'react-icons/md' +import { Button, DeleteIcon, TightInputForm } from '@archipel/ui' + +let keysAndLabels = null + +class Filter { + constructor (props) { + let { id, name, attribute, assign } = props || {} + this.id = id || null + this.name = name || null + this.active = true + this.attributes = [] + if (attribute || assign) this.addAttribute(attribute, assign) + } + + setIdentity (id, name) { + if (!this.id) this.id = id + if (!this.name) this.name = name + } + + addAttribute (attribute, assign) { + this.attributes.push({ attribute, assign }) + } + + delAttribute (attribute, assign) { + let pos = this.attributes.findIndex(e => e.attribute === attribute && e.assign === assign) + this.attributes.splice(pos, 1) + } + + getAttributes (onDisplay) { + if (!onDisplay && !this.active) return [] + return this.attributes + } + + toggle () { + this.active = !this.active + return this.active + } +} + +function FilterEditor (props) { + let { addFilter, title } = props + let [filter, setFilter] = useState(new Filter()) + let [rerender, forceRerender] = useReducer(x => x + 1, 0) + let [name, setName] = useState('') + let [newAttribute, setNewAttribute] = useState(null) + let [newAssign, setNewAssign] = useState(null) + + function addAttribute (attribute, assign) { + filter.addAttribute(attribute, assign) + } + + function delAttribute (attribute, assign) { + filter.delAttribute(attribute, assign) + forceRerender() + } + + function setIdentity (id, name) { + filter.setIdentity(id, name) + setName('') + } + + function onSubmit () { + addAttribute(newAttribute, newAssign) + setNewAssign(null) + } + + function submitFilter () { + addFilter(filter) + setFilter(new Filter()) + } + + return ( +
    + {title || null} + {filter.name + ? {filter.name} + : setName(e.target.value)} + onSubmit={() => setIdentity(`${hubController.getArchive()}/metadataFilter/${name}`, name)} /> + } +
    +
    + {filter.getAttributes().map( + (e, i) => +
    + {keysAndLabels.labelFromKey(e.attribute)}: {e.assign} + delAttribute(e.attribute, e.assign)} /> +
    + )} +
    +
    + + setNewAssign(e.target.value)} + onSubmit={onSubmit} + widthUnits={7} + addForm /> + +
    +
    + ) +} + +function FilterDisplay (props) { + const { filter, deleteFilter, toggleFilter } = props + let [active, setActive] = useState(filter.active) + + function toggle () { + let { cb, index } = toggleFilter + setActive(cb(index)) + } + + let color = active ? 'green-light' : 'red-light' + return ( +
    +
    +
    + { filter.name } +
    + +
    + {filter.getAttributes(true).map((e, i) => + {keysAndLabels.labelFromKey(e.attribute)}: {e.assign})} +
    + ) +} + +function MetadataRecordCard (props) { + const { metadata } = props + const { ofCategory: category, ...restMeta } = metadata + if (!category) return null + + let [expanded, setExpand] = useState(false) + let height = '' + if (!expanded) height = ' max-h-64' + return
    +

    {category && Categories.getLabel(Object.keys(category.values)[0])}

    +
    + +
    + +
    +} + +export default function MetadataHub (props) { + let [metadata, setMetadata] = useState(null) + let [filterList, setFilterList] = useState([]) + let [limit, setLimit] = useState(hubController.limit()) + + useEffect(() => { + hubController.setArchive(props.params.archive) + keysAndLabels = getAllKeysAndLabels() + }, [props]) + + useEffect(() => { + updateMetadata() + }, [filterList]) + + async function updateMetadata () { + let res = await hubController.search(filterList) + setMetadata(res) + } + + function appendFilter (filter) { + let list = [...filterList] + list.push(filter) + setFilterList(list) + } + + function deleteFilter (index) { + let list = [...filterList] + list.splice(index, 1) + setFilterList(list) + } + + function toggleFilter (index) { + let list = [...filterList] + let filterState = list[index].toggle() + setFilterList(list) + return filterState + } + + function adjustLimit (e) { + let limit = e.target.value + setLimit(limit) + hubController.limit(limit) + } + + return ( +
    + +
    + {/* Define new filter: */} + + Filter: + {filterList.map((e, i) =>
    + deleteFilter(i)} toggleFilter={{ cb: toggleFilter, index: i }} /> +
    )} +
    + +
    + +
    +
    + + +
    +
    + +
    + {metadata && metadata.map((e, i) => )} +
    + +
    +
    + ) +} diff --git a/packages/app/src/features/metadata/MetadataLink.js b/packages/app/src/features/metadata/MetadataLink.js new file mode 100644 index 0000000..d7efd01 --- /dev/null +++ b/packages/app/src/features/metadata/MetadataLink.js @@ -0,0 +1,19 @@ +/* +Component which may be inlcuded anywhere in the app, shows some metadata, and redirects to the MetadataHub, filtered for the metadata. +*/ +import React from 'react' +import { Link } from '../../lib/router' + +export default function MetadataLink (props) { + let { className, target } = props + console.log('MetadataLink', target) + className = className || '' + let cls = 'inline-block p-2 text-pink-dark font-bold ' + className + let link = 'archive/:archive/hub/' + target + + return ( + +
    #{target}
    + + ) +} diff --git a/packages/app/src/features/metadata/editorController.js b/packages/app/src/features/metadata/editorController.js new file mode 100644 index 0000000..52024af --- /dev/null +++ b/packages/app/src/features/metadata/editorController.js @@ -0,0 +1,187 @@ +'use strict' + +import { getApi } from '../../lib/api' +import { _initialSetMetadata, getMetadata } from './editorStore' +import getSchema, { getCategoryFromMimeType, validCategory } from './schemas' +import { CATEGORY, triplesToMetadata, metadataToTriples, cloneObject } from './util' + +export function EditorController (props) { + console.log('New FMC', props) + this._ready = false + this.controllerName = props.name + this.constants = { + archiveKey: props.archive, + ID: props.ID, + type: props.mimetype || props.type + } + this._schema = null + this.state = { + category: null + } + this.init() +} + +EditorController.prototype.init = async function () { + if (this._ready) return + if (!this.constants.ID) throw new Error('No ID!') + let tripleStore = await getApi().then((apis) => apis.hypergraph) + if (!tripleStore) throw new Error('Can not connect to TripleStore') + this.constants.tripleStore = tripleStore + + await this.getCategory() + await this.getSchema() + await this._getActualMetadata() + + this._ready = true +} + +// Define setState function to allow for easy switch +// to using react setState or similar state controllers +EditorController.prototype.setState = function (props) { + const { ID } = this.constants + for (let i of Object.keys(props)) { + if (i === 'metadata') { + _initialSetMetadata(ID, props[i]) + continue + } + this.state[i] = props[i] + } +} + +/* ### set and get Category ### */ + +// Needs to be sync +EditorController.prototype.category = function () { + return this.state.category || null +} + +EditorController.prototype.getCategory = async function () { + if (this.state.category) return this.state.category + let { tripleStore, archiveKey, ID } = this.constants + + let queryRes = await tripleStore.get( + archiveKey, { subject: ID, predicate: CATEGORY } + ) + + if (queryRes.length < 1) return this._setDefaultCategory() + + if (queryRes.length === 1 && validCategory(queryRes[0].object)) { + this.setState({ category: queryRes[0].object }) + return queryRes[0].object + } + if (queryRes.length === 1 && !validCategory(queryRes[0].object)) { + console.warn('Invalid metadata category, reset to type default') + return this._setDefaultCategory() + } + console.warn('Category ambiguous, reset type default') + await tripleStore.del(archiveKey, queryRes) + return this._setDefaultCategory() +} + +EditorController.prototype.setCategory = async function (category) { + await this._setCategory(category) + this._newSchema() +} + +EditorController.prototype._setCategory = async function (category) { + if (!validCategory(category)) return this._setDefaultCategory() + let { tripleStore, archiveKey, ID } = this.constants + await tripleStore.put(archiveKey, + { subject: ID, predicate: CATEGORY, object: category }) + this.setState({ category }) +} + +EditorController.prototype._setDefaultCategory = async function () { + let { type } = this.constants + if (!type) { + console.warn('No mimeType, setting metadata category to "resource"') + type = 'resource' + } + let category + if (validCategory(type)) { + category = type + } else { + category = getCategoryFromMimeType(type) + } + await this._setCategory(category) + return category +} + +/* Get Schema according to category */ + +EditorController.prototype.getSchema = async function () { + if (this._schema) return cloneObject(this._schema) + if (!this.state.category) await this.getCategory() + this._schema = await getSchema(this.state.category) + return cloneObject(this._schema) +} + +EditorController.prototype._newSchema = async function () { + if (!this.state.category) await this.getCategory() + this._schema = getSchema(this.state.category) + let metadata = await this.getSchema() + let oldMetadata = getMetadata(this.constants.ID) + + for (let entryKey of Object.keys(oldMetadata)) { + if (!metadata[entryKey]) metadata[entryKey] = {} + metadata[entryKey].values = { ...oldMetadata[entryKey].values } + } + this.setState({ metadata }) +} + +/* Work on the metadata-Object */ + +EditorController.prototype._getActualMetadata = async function () { + let { tripleStore, archiveKey, ID } = this.constants + + let fileTriples = await tripleStore.get(archiveKey, { subject: ID }) + let schema = await this.getSchema() + let metadata = triplesToMetadata(fileTriples, schema, 'actualValue') + + this.setState({ metadata }) +} + +EditorController.prototype.setDraftValue = async function (entryKey, draftValue) { + if (!entryKey || !draftValue) return null + if (draftValue.value) draftValue = draftValue.value + let { ID } = this.constants + let metadata = { ...getMetadata(ID) } + let metadataEntry = metadata[entryKey] + if (!metadataEntry.values) metadataEntry.values = {} + if (metadataEntry.values[draftValue]) { + if (metadataEntry.values[draftValue].state === 'actual') return + if (metadataEntry.values[draftValue].state === 'draft') return + if (metadataEntry.values[draftValue].state === 'delete') { + metadataEntry.values[draftValue].state = 'draft' + this.setState({ metadata }) + return + } + } + if (metadataEntry.singleType) { + for (let valueKey of Object.keys(metadataEntry.values)) { + metadataEntry.values[valueKey].state = 'delete' + } + } + metadataEntry.values[draftValue] = { state: 'draft', value: draftValue } + this.setState({ metadata }) +} + +EditorController.prototype.setDeleteValue = async function (entryKey, value) { + if (value.value) value = value.value + let { ID } = this.constants + let metadata = { ...getMetadata(ID) } + let metadataEntry = metadata[entryKey] + if (metadataEntry.values[value]) metadataEntry.values[value].state = 'delete' + this.setState({ metadata }) +} + +EditorController.prototype.writeChanges = async function (props) { + // TODO: Verify Metadata + if (!props) props = {} + let { onUnmount } = props + let { archiveKey, ID, tripleStore } = this.constants + let { writeTriples, deleteTriples } = metadataToTriples(ID, await getMetadata(ID), 'toBeValue', await this.getSchema()) + await tripleStore.put(archiveKey, writeTriples) + await tripleStore.del(archiveKey, deleteTriples) + if (!onUnmount) this._getActualMetadata(true) +} \ No newline at end of file diff --git a/packages/app/src/features/metadata/editorStore.js b/packages/app/src/features/metadata/editorStore.js new file mode 100644 index 0000000..8435dce --- /dev/null +++ b/packages/app/src/features/metadata/editorStore.js @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react' +import { Store } from '../../lib/store' + +let metadataStore = new Store('actualMetadata') + +export function _initialSetMetadata (fileID, metadata) { + metadataStore.set(fileID, metadata) + metadataStore.trigger(fileID) +} + +// export function _setMetadataActualValue (fileID, entryID, actualValue) { +// if (!Array.isArray(actualValue)) throw new Error('Metadata entries have to be arrays!') +// let metadata = metadataStore.get(fileID) +// if (!metadata[entryID]) metadata[entryID] = {} +// metadata[entryID].actualValue = actualValue +// metadataStore.set(fileID, metadata) +// } + +// export function _setMetadataToBeValue (fileID, entryID, toBeValue) { +// if (!Array.isArray(toBeValue)) throw new Error('Metadata entries have to be arrays!') +// let metadata = metadataStore.get(fileID) +// if (!metadata[entryID]) metadata[entryID] = {} +// // console.log('in store set:', toBeValue) +// metadata[entryID].toBeValue = toBeValue +// // console.log('in store set', metadata) +// metadataStore.set(fileID, metadata) +// } + +export function _setMetadataValue (fileID, entryID, value) { + console.log('setMetadataValue', entryID, value) + let metadata = metadataStore.get(fileID) + metadata[entryID].values[value.value] = value + metadataStore.set(metadata) +} + +export function getMetadata (fileID) { + let metadata = metadataStore.get(fileID) + if (Object.keys(metadata).length > 0) return metadata + return null +} + +export function watchMetadata (fileID, cb, init) { + metadataStore.watch(fileID, cb, init) +} + +export function useMetadata (fileID) { + const [state, setState] = useState(() => metadataStore.get(fileID)) + useEffect(() => { + metadataStore.watch(fileID, watcher, true) + + function watcher (metadata) { + setState(metadata) + } + return () => metadataStore.unwatch(fileID, watcher) + }, [state]) + return state || {} +} diff --git a/packages/app/src/features/metadata/hubController.js b/packages/app/src/features/metadata/hubController.js new file mode 100644 index 0000000..db8b4a2 --- /dev/null +++ b/packages/app/src/features/metadata/hubController.js @@ -0,0 +1,101 @@ +'use strict' + +import { useEffect } from 'react' +import getSchema, { Categories, getAllKeysAndLabels } from './schemas' +import { CATEGORY, triplesToMetadata } from './util' +import MemorizingTripleStore from './MemorizingTripleStore' + +let tripleStore = new MemorizingTripleStore() +// initTripleStore() +// async function initTripleStore () { +// tripleStore = await getApi().then((apis) => apis.hypergraph) +// } + +let archive = null +let limit = 20 + +export default function hubController (props) { +} + +hubController.categories = function () { + return Categories.getLabel(-1) +} + +hubController.setArchive = function (archiveKey) { + archive = archiveKey +} + +hubController.getArchive = function () { + return archive +} + +hubController.limit = function (newLimit) { + if (newLimit) limit = newLimit + return limit +} + +// hubController.queryCategory = async function (category) { +// console.log('hCqueryCategory', category, tripleStore) +// category = Categories.getID(category) +// console.log('hCqueryCategory', category, tripleStore) +// let res = await tripleStore.get(archive, { predicate: CATEGORY, object: category }) +// console.log('hCqueryCategory', res) +// let ret = [] +// res.forEach(triple => ret.push(this.getMetadataToSubject(triple.subject, category))) +// return Promise.all(ret) +// } + +// hubController.queryPredicate = async function (predicate, object) { +// let res = await tripleStore.get(archive, { predicate: predicate || null, object: object || null }) +// console.log('queryPredicate', res) +// let ret = [] +// res.forEach(triple => ret.push(this.getMetadataToSubject(triple.subject))) +// return Promise.all(ret) +// } + +hubController.querySubject = function (subject) { + return tripleStore.get(archive, { subject }) +} + +hubController.search = async function (filterList) { + let attributes = [] + for (let filter of filterList) { + attributes.push(...filter.getAttributes()) + } + let triples = [] + for (let pair of attributes) { + triples.push({ predicate: pair.attribute, object: pair.assign }) + } + let res = await tripleStore.searchSubjects(archive, triples, { limit }) + let ret = [] + res.forEach(triple => ret.push(this.getMetadataToSubject(triple.subject))) + return Promise.all(ret) +} + +hubController.getMetadataToSubject = async function (subject, category) { + if (!category) { + let { object } = await tripleStore.get(archive, { subject, predicate: CATEGORY }) + category = object + } + + let schema + if (category) schema = getSchema(category) + let triples = await this.querySubject(subject) + return triplesToMetadata(triples, schema) +} + +hubController.getPossibleFilters = function () { + return getAllKeysAndLabels() +} + +// function GetTripleStore (interval) { +// let memorizingTripleStore = new MemorizingTripleStore() + +// useEffect(() => { +// memorizingTripleStore.activateCleaner(interval) + +// return () => memorizingTripleStore.clearCleaner() +// }, []) + +// return memorizingTripleStore +// } diff --git a/packages/app/src/features/metadata/index.js b/packages/app/src/features/metadata/index.js new file mode 100644 index 0000000..b74ab91 --- /dev/null +++ b/packages/app/src/features/metadata/index.js @@ -0,0 +1,17 @@ +import { FileMetadataEditor } from './MetadataEditor' +import MetadataHub from './MetadataHub' + +import registry from '../../lib/component-registry' +import { registerRoute, registerElement } from '../../lib/router' + +export default function start () { + registry.add('fileSidebar', FileMetadataEditor, { title: 'MetadataEditor' }) + + registerRoute('archive/:archive/hub', MetadataHub) + + registerElement('archive/:archive', { + link: [ + { name: 'Hub', href: 'archive/:archive/hub', weight: 1 } + ] + }) +} diff --git a/packages/app/src/features/metadata/issues.md b/packages/app/src/features/metadata/issues.md new file mode 100644 index 0000000..88db8c4 --- /dev/null +++ b/packages/app/src/features/metadata/issues.md @@ -0,0 +1,6 @@ +Issues in the metadata feature. + +## functionality + +1. In [MetadataHub](./MetadataHub.js), resolve files on person? + - what happens if one would like to not only browse for artist=homer, but for (artist|interpreter|author|photographer)=homer ? \ No newline at end of file diff --git a/packages/app/src/features/metadata/schemas.js b/packages/app/src/features/metadata/schemas.js new file mode 100644 index 0000000..aabfffc --- /dev/null +++ b/packages/app/src/features/metadata/schemas.js @@ -0,0 +1,233 @@ +import SimplSchema from 'simpl-schema' +import { cloneObject } from './util' + +const CArray = Array +CArray.prototype.inArray = function (item) { + return this.some(elem => elem === item) +} +CArray.prototype.pushUnique = function (item) { + if (!this.inArray(item)) { + this.push(item) + return true + } + return false +} + +const CategoryIDs = [ + 'resource', 'file', 'image', 'person', 'address', 'text', 'article' +] +const CategoryLabels = [ + 'Resource', 'File', 'Image', 'Person', 'Address', 'Text', 'Article' +] +export const Categories = [CategoryIDs, CategoryLabels] + +Categories.getID = function (label) { + if (label < 0) return this[0] + return this[0][this[1].findIndex(i => i === label)] +} + +Categories.getLabel = function (id) { + if (id < 0) return this[1] + return this[1][this[0].findIndex(i => i === id)] +} + +export default function getSchema (category) { + return cloneObject(_getSchema(category).schema()) +} + +function _getSchema (category) { + switch (category) { + case 'adress': + return adressSchema + case 'article': + return articleSchema + case 'file': + return fileSchema + case 'image': + return imageSchema + case 'person': + return personSchema + case 'text': + return textSchema + default: + return resourceSchema + } +} + +const allKeysAndLabels = { keys: [], labels: [] } +allKeysAndLabels.init = false +allKeysAndLabels.labelFromKey = function (key) { + return this.labels[this.keys.findIndex(elem => elem === key)] +} +allKeysAndLabels.keyFromLabel = function (label) { + return this.keys[this.labels.findIndex(elem => elem === label)] +} +allKeysAndLabels.set = function (keysAndLabels) { + let { keys, labels } = keysAndLabels + if (keys.length !== labels.length) throw new Error('keys and labels should be in one-to-one order and hence of euqal length') + this.keys = keys + this.labels = labels + allKeysAndLabels.init = true +} + +export function getAllKeysAndLabels () { + if (allKeysAndLabels.init) return allKeysAndLabels + let keys = new CArray() + let labels = new CArray() + for (let id of Categories.getID(-1)) { + let schema = _getSchema(id) + schema._schemaKeys.forEach(key => { + let res = keys.pushUnique(key) + if (res) labels.push(schema.schema()[key].label) + }) + } + allKeysAndLabels.set({ keys, labels }) + return allKeysAndLabels +} + +export function getCategoryFromMimeType (mime) { + switch (mime) { + case 'text/plain': + return 'text' + case 'application/pdf': + return 'text' + case 'image/jpeg': + return 'image' + default: + return 'file' + } +} + +// SimplSchema.addValidator(singleType) +SimplSchema.extendOptions({ + singleType: Boolean +}) + +const resourceSchema = new SimplSchema({ + // hasLabel: String, // automatically by SimplSchema + hasDescription: { + type: String, + label: 'Description' + }, + hasTag: { + type: String, + label: 'Tag' + } +}) +resourceSchema.name = 'resource' + +const adressSchema = new SimplSchema({ + hasCountry: { + type: String, + label: 'Country' + }, + hasCity: { + type: String, + label: 'City' + }, + hasPostalCode: { + type: String, + label: 'Postal code' + }, + hasStreetNumber: { + type: String, + label: 'Street and number' + } +}).extend(resourceSchema) +adressSchema.name = 'adress' + +const personSchema = new SimplSchema({ + hasFirstName: { + type: String, + label: 'First name', + singleType: true + }, + hasMiddleNames: { + type: String, + label: 'Middle names' + }, + hasLastName: { + type: String, + label: 'Last Name', + singleType: true + }, + hasAdress: { + type: adressSchema, + label: 'Adress' + } +}) +personSchema.extend(resourceSchema) +personSchema.name = 'person' + +const textSchema = new SimplSchema({ + hasAbstract: { + type: String, + label: 'Abstract' + }, + hasLanguage: { + type: String, + label: 'Language' + } +}).extend(resourceSchema) +textSchema.name = 'text' + +const articleSchema = new SimplSchema({ + hasAuthor: { + type: personSchema, + label: 'Author' + }, + hasDateOfCreation: { + type: Date, + label: 'Release Date' + }, + hasPlaceOfCreation: { + type: adressSchema, + label: 'Place of Publishing' + } +}).extend(textSchema) +articleSchema.name = 'article' + +const imageSchema = new SimplSchema({ + hasTitle: { + type: String, + label: 'Title', + singleType: true }, + hasLocalOrigin: { + type: String, + label: 'Location' }, + hasDateOfCreation: { + type: Date, + label: 'Date of Creation', + singleType: true }, + hasCreator: { + type: personSchema, + label: 'Creator' } +}) +imageSchema.extend(resourceSchema) +imageSchema.name = 'image' + +const fileSchema = new SimplSchema({ + hasFileName: { + type: String, + label: 'File name', + singleType: true }, + hasPath: { + type: String, + label: 'File path', + singleType: true }, + hasCreator: { + type: personSchema, + label: 'Creator', + singleType: true + } +}) +fileSchema.extend(resourceSchema) +fileSchema.name = 'file' + +export function validCategory (category) { + return CategoryIDs.includes(category) +} + +export function shallowObjectClone (object) { + return Object.assign({}, object) +} \ No newline at end of file diff --git a/packages/app/src/features/metadata/util.js b/packages/app/src/features/metadata/util.js new file mode 100644 index 0000000..acb433f --- /dev/null +++ b/packages/app/src/features/metadata/util.js @@ -0,0 +1,53 @@ +'use strict' + +export const CATEGORY = 'ofCategory' + +export function triplesToMetadata (triples, metadata) { + if (!metadata) metadata = {} + for (let triple of triples) { + let { predicate, object } = triple + if (!metadata[predicate]) { + metadata[predicate] = {} + } + + let dataEntry = metadata[predicate] + if (!dataEntry.label) dataEntry.label = predicate + if (!dataEntry.values) dataEntry.values = {} + if (!dataEntry.values[object]) dataEntry.values[object] = {} + dataEntry.values[object].state = 'actual' + dataEntry.values[object].value = object + } + + // if (metadata[CATEGORY]) delete metadata[CATEGORY] + return metadata +} + +export function metadataToTriples (subject, metadata) { + let writeTriples = [] + let deleteTriples = [] + for (let predicate of Object.keys(metadata)) { + let values = metadata[predicate].values + if (!values) continue + for (let value of Object.keys(values)) { + if (values[value].state === 'actual') continue + if (values[value].state === 'draft') { + writeTriples.push({ subject, predicate, object: values[value].value }) + continue + } + if (values[value].state === 'delete') deleteTriples.push({ subject, predicate, object: values[value].value }) + } + } + return { writeTriples, deleteTriples } +} + +export function cloneObject (obj) { + var clone = {} + for (var i in obj) { + if (obj[i] !== null && typeof obj[i] === 'object') { + clone[i] = cloneObject(obj[i]) + } else { + clone[i] = obj[i] + } + } + return clone +} \ No newline at end of file diff --git a/packages/app/src/init.js b/packages/app/src/init.js index 99617ad..4d643ca 100644 --- a/packages/app/src/init.js +++ b/packages/app/src/init.js @@ -6,6 +6,7 @@ import Panels from './foo/panels' import archiveInit from './features/archive' import driveInit from './features/drive' import graphInit from './features/graph' +import metadataInit from './features/metadata' import { ArchiveListWrapper, ArchiveTabsWrapper, NoArchive } from './features/archive/ArchiveScreen.js' import ArchiveInfo from './features/archive/ArchiveInfo' @@ -41,7 +42,7 @@ function defaultInit () { } export default function init (extensions) { - let inits = [defaultInit, archiveInit, driveInit, graphInit] + let inits = [defaultInit, archiveInit, driveInit, metadataInit] // graphInit if (extensions) inits = inits.concat(extensions) inits.forEach(ext => { diff --git a/packages/backend/structures/hypergraph.js b/packages/backend/structures/hypergraph.js index 4ae0dbd..8164977 100644 --- a/packages/backend/structures/hypergraph.js +++ b/packages/backend/structures/hypergraph.js @@ -21,13 +21,36 @@ exports.rpc = (api, opts) => { async get (key, query) { const db = await getHypergraph(this.session, key) - const res = await db.get(query) - return res + return db.get(query) }, async put (key, triples) { const db = await getHypergraph(this.session, key) return db.put(triples) + }, + + async del (key, triples) { + const db = await getHypergraph(this.session, key) + await db.del(triples) + // , (err, res) => { + // if (err) return console.warn('Error deleting entries:', err) + // console.log('Deleted Entries:', res) + // }) + }, + + async searchSubjects (key, pattern, opts) { + const db = await getHypergraph(this.session, key) + return db.searchSubjects(pattern, opts) + }, + + async query (key, query) { + const db = await getHypergraph(this.session, key) + return db.query(query) + // , (err, res) => { + // if (err) console.warn('Error at query', query, res) + // console.log('queried for', query, 'and got', res) + // }) + // return res } } @@ -35,7 +58,7 @@ exports.rpc = (api, opts) => { if (!session.library) throw new Error('No library open.') const library = await api.hyperlib.get(session.library) const archive = await library.getArchive(key) - let structure = await archive.getStructure({ type: 'hypergraph'}) + let structure = await archive.getStructure({ type: 'hypergraph' }) if (!structure) { structure = await archive.createStructure('hypergraph') } @@ -84,6 +107,64 @@ exports.structure = (opts, api) => { return state }, + async get (triple, opts) { + return new Promise((resolve, reject) => { + db.get(triple, opts, (err, res) => { + if (err) reject(err) + resolve(res) + }) + }) + }, + + /* AdHoc Solution for bad search of hyper-graph-db + Problem: Dies not work with multiple search criteria + TODO fork hyper-graph-db and implement working solution */ + /* returns an array of { subject: 'xyz' } object, of matching all criteria */ + + async searchSubjects (triples, opts) { + if (!triples || !Array.isArray(triples)) return null + let limit = opts + + // get results for all single criteria + let res = [] + triples.forEach(t => res.push(self.get(t))) + res = await Promise.all(res) + res = res.flat() + + // scip the rest, in case of only one criterium + if (triples.length <= 1) { + if (limit < res.length) res = res.slice(0, limit) + return res.map(triple => { return { subject: triple.subject } }) + } + + // find those matching all criteriy + let indices = new Array(res.length) + indices.fill(1, 0) + for (let i = 0; i < res.length; i++) { + for (let j = i + 1; j < res.length; j++) { + if (res[i].subject === res[j].subject) { + indices[i]++ // = indices[i] + 1 + indices[j]++ // = indices[j] + 1 + } + } + } + + // construct the returned array + let ret = [] + indices.forEach((n, i) => { + if (n === triples.length) { + let append = true + ret.forEach(triple => { + if (res[i].subject === triple.subject) append = false + }) + if (append) ret.push({ subject: res[i].subject }) + } + }) + + if (limit < ret.length) ret = ret.slice(0, limit) + return ret + }, + authorized (key) { key = Buffer.from(key, 'hex') return new Promise((resolve, reject) => { @@ -105,7 +186,6 @@ exports.structure = (opts, api) => { // Hack: Do a write after the auth is complete. // Without this, hyperdrive breaks when loading the stat // for the root folder (/). I think this is a bug in hyperdb. - console.log('authorized writer') resolve(true) } }) @@ -125,15 +205,17 @@ exports.structure = (opts, api) => { // } // }, - api: {} + api: { } } // Expose methods from hypergraph as api. // Todo: Document available api. - const asyncFuncs = ['ready', 'put', 'get'] + const asyncFuncs = ['ready', 'put', 'del'] asyncFuncs.forEach(func => { self.api[func] = pify(db[func].bind(db)) }) + self.api['get'] = self.get.bind(self) + self.api['searchSubjects'] = self.searchSubjects.bind(self) return self } diff --git a/packages/common/util/deepEqual.js b/packages/common/util/deepEqual.js new file mode 100644 index 0000000..ff488ff --- /dev/null +++ b/packages/common/util/deepEqual.js @@ -0,0 +1,13 @@ +export default function deepEqual (a, b) { + // if (a !== b) return false // unequal by reference + + // according to http://www.mattzeunert.com/2016/01/28/javascript-deep-equal.html + // the following is faster than module deep-equal https://github.com/substack/node-deep-equal/ + // so subject to high quality optimization + let sa = JSON.stringify(a) + let sb = JSON.stringify(b) + + if (sa !== sb) return false + + return true +} \ No newline at end of file diff --git a/packages/ui/src/components/DeleteIcon.js b/packages/ui/src/components/DeleteIcon.js new file mode 100644 index 0000000..ecb99c1 --- /dev/null +++ b/packages/ui/src/components/DeleteIcon.js @@ -0,0 +1,9 @@ +import React from 'react' +import { MdClear } from 'react-icons/md' + +export default function DeleteIcon (props) { + let { size, onClick } = props + return +} diff --git a/packages/ui/src/components/TightInputForm.js b/packages/ui/src/components/TightInputForm.js new file mode 100644 index 0000000..ceaa62a --- /dev/null +++ b/packages/ui/src/components/TightInputForm.js @@ -0,0 +1,30 @@ +import React from 'react' +import { cls } from '../util.js' +import { MdAdd, MdKeyboardReturn } from 'react-icons/md' + +let clss = ['inline-flex items-center'] + +const TightInputForm = (props) => { + let { type, onChange, value, placeholder, onSubmit, addForm, buttonSize, widthUnits, ...rest } = props + props = rest + if (!widthUnits) widthUnits = 7 + if (!value) value = '' + return ( +
    + +
    + + + ) +} + +export default TightInputForm diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index 63a0cc7..33f7e4a 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -11,6 +11,8 @@ export { default as Button, FloatingButton } from './components/Button' // C export { default as Card } from './components/Card' export { default as Checkbox } from './components/Checkbox' +// D +export { default as DeleteIcon } from './components/DeleteIcon' // E export { default as ExpandButton } from './components/ExpandButton' // F @@ -35,8 +37,8 @@ export { default as StructuresCheckList } from './components/StructuresCheckList // T export { default as Tabs } from './components/Tabs' +export { default as TightInputForm } from './components/TightInputForm' export { default as Tree } from './components/Tree/index.js' - // Other export { proplist, cls } from './util.js' diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index ff1090a..4c13004 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -520,6 +520,12 @@ module.exports = { '4/5': '80%', '1/6': '16.66667%', '5/6': '83.33333%', + '1/7': '14.28571%', + '2/7': '28.57143%', + '3/7': '42.85714%', + '4/7': '57.14288%', + '5/7': '71.42857%', + '6/7': '85.71429%', 'full': '100%', 'screen': '100vw', 'main': '50rem' @@ -599,6 +605,21 @@ module.exports = { minHeight: { '0': '0', + '1': '0.25rem', + '2': '0.5rem', + '3': '0.75rem', + '4': '1rem', + '5': '1.25rem', + '6': '1.5rem', + '8': '2rem', + '10': '2.5rem', + '12': '3rem', + '16': '4rem', + '24': '6rem', + '32': '8rem', + '48': '12rem', + '64': '16rem', + 'full': '100%', 'screen': '100vh' }, @@ -649,6 +670,8 @@ module.exports = { maxHeight: { 'full': '100%', + '48': '12rem', + '64': '16rem', 'screen': '100vh', },