upend/webui/src/lib/components/Inspect.svelte

622 lines
14 KiB
Svelte

<script lang="ts">
import EntryView, { type Widget } from './EntryView.svelte';
import { useEntity } from '$lib/entity';
import UpObject from './display/UpObject.svelte';
import { createEventDispatcher } from 'svelte';
import { derived, type Readable } from 'svelte/store';
import { Query, type UpEntry } from '@upnd/upend';
import Spinner from './utils/Spinner.svelte';
import NotesEditor from './utils/NotesEditor.svelte';
import type { WidgetChange } from '../types/base';
import type { Address, EntityInfo } from '@upnd/upend/types';
import IconButton from './utils/IconButton.svelte';
import BlobViewer from './display/BlobViewer.svelte';
import { i18n } from '../i18n';
import EntryList from './widgets/EntryList.svelte';
import api from '$lib/api';
import EntityList from './widgets/EntityList.svelte';
import { ATTR_IN, ATTR_KEY, ATTR_LABEL, ATTR_OF } from '@upnd/upend/constants';
import InspectGroups from './InspectGroups.svelte';
import InspectTypeEditor from './InspectTypeEditor.svelte';
import LabelBorder from './utils/LabelBorder.svelte';
import { debug } from 'debug';
import { Any } from '@upnd/upend/query';
import { isDefined } from '$lib/util/werk';
import Icon from '$lib/components/utils/Icon.svelte';
const dbg = debug('kestrel:Inspect');
const dispatch = createEventDispatcher<{
resolved: string[];
close: void;
}>();
export let address: string;
export let detail: boolean;
let showAsEntries = false;
let highlightedType: string | undefined;
let blobHandled = false;
$: ({ entity, entityInfo, error, revalidate } = useEntity(address));
$: allTypes = derived(
entityInfo,
($entityInfo, set) => {
getAllTypes($entityInfo!).then((allTypes) => {
set(allTypes);
});
},
{}
) as Readable<{
[key: string]: {
labels: string[];
attributes: string[];
};
}>;
$: sortedTypes = Object.entries($allTypes)
.sort(([a, _], [b, __]) => a.localeCompare(b))
.sort(([_, a], [__, b]) => a.attributes.length - b.attributes.length);
async function getAllTypes(entityInfo: EntityInfo) {
const allTypes: Record<Address, { labels: string[]; attributes: string[] }> = {};
if (!entityInfo) {
return {};
}
const typeAddresses: string[] = [
await api.getAddress(entityInfo.t),
...($entity?.attr[ATTR_IN] || []).map((e) => e.value.c as string)
];
const typeAddressesIn = typeAddresses.map((addr) => `@${addr}`).join(' ');
const labelsQuery = await api.query(`(matches (in ${typeAddressesIn}) "${ATTR_LABEL}" ?)`);
typeAddresses.forEach((address) => {
let labels = labelsQuery.getObject(address).identify();
let typeLabel: string | undefined;
if (typeLabel) {
labels.unshift(typeLabel);
}
allTypes[address] = {
labels,
attributes: []
};
});
const attributes = await api.query(`(matches ? "${ATTR_OF}" (in ${typeAddressesIn}))`);
await Promise.all(
typeAddresses.map(async (address) => {
allTypes[address].attributes = (
await Promise.all(
(attributes.getObject(address).attr[`~${ATTR_OF}`] || []).map(async (e) => {
try {
const { t, c } = await api.addressToComponents(e.entity);
if (t == 'Attribute') {
return c;
}
} catch (err) {
console.error(err);
return undefined;
}
})
)
).filter(isDefined);
})
);
const result: Record<Address, { labels: string[]; attributes: string[] }> = {};
Object.keys(allTypes).forEach((addr) => {
if (allTypes[addr].attributes.length > 0) {
result[addr] = allTypes[addr];
}
});
return result;
}
let untypedProperties = [] as UpEntry[];
let untypedLinks = [] as UpEntry[];
$: {
untypedProperties = [];
untypedLinks = [];
($entity?.attributes || []).forEach((entry) => {
const entryTypes = Object.entries($allTypes || {}).filter(([_, t]) =>
t.attributes.includes(entry.attribute)
);
if (entryTypes.length === 0) {
if (entry.value.t === 'Address') {
untypedLinks.push(entry);
} else {
untypedProperties.push(entry);
}
}
});
untypedProperties = untypedProperties;
untypedLinks = untypedLinks;
}
$: filteredUntypedProperties = untypedProperties.filter(
(entry) =>
![
ATTR_LABEL,
ATTR_IN,
ATTR_KEY,
'NOTE',
'LAST_VISITED',
'NUM_VISITED',
'LAST_ATTRIBUTE_WIDGET',
'COVER'
].includes(entry.attribute)
);
$: currentUntypedProperties = filteredUntypedProperties;
$: filteredUntypedLinks = untypedLinks.filter(
(entry) => ![ATTR_IN, ATTR_OF, 'COVER'].includes(entry.attribute)
);
$: currentUntypedLinks = filteredUntypedLinks;
$: currentBacklinks =
$entity?.backlinks.filter((entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute)) || [];
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
let attributesUsed: UpEntry[] = [];
$: {
if ($entityInfo?.t === 'Attribute') {
api
.query(`(matches ? "${$entityInfo.c}" ?)`)
.then((result) => (attributesUsed = result.entries));
}
}
let correctlyTagged: Address[] | undefined;
let incorrectlyTagged: Address[] | undefined;
$: {
if ($entity?.attr[`~${ATTR_OF}`]) {
fetchCorrectlyTagged();
}
}
async function fetchCorrectlyTagged() {
if (!$entity?.attr[`~${ATTR_OF}`]?.length) {
return;
}
const allAttributes = (
await Promise.all(
($entity?.attr[`~${ATTR_OF}`] ?? []).map(async (e) => {
return { address: e.address, components: await api.addressToComponents(e.entity) };
})
)
)
.filter((ac) => ac.components.t == 'Attribute')
.filter(isDefined);
const attributeRequiredQuery = await api.query(
Query.matches(
allAttributes.map((ac) => `@${ac.address}`),
'TYPE_REQUIRED',
Any
)
);
const requiredAttributes = allAttributes
.filter((ac) => {
return attributeRequiredQuery.getObject(ac.address).attr['TYPE_REQUIRED']?.length;
})
.map((ac) => ac.components.c as string);
const attributeQuery = await api.query(
Query.matches(
tagged.map((t) => `@${t.entity}`),
requiredAttributes,
Any
)
);
correctlyTagged = [];
incorrectlyTagged = [];
for (const element of tagged) {
const entity = attributeQuery.getObject(element.entity);
if (requiredAttributes.every((attr) => entity.attr[attr])) {
correctlyTagged = [...correctlyTagged, element.entity];
} else {
incorrectlyTagged = [...incorrectlyTagged, element.entity];
}
}
}
async function onChange(ev: CustomEvent<WidgetChange>) {
dbg('onChange', ev.detail);
const changes = Array.isArray(ev.detail) ? ev.detail : [ev.detail];
for (const change of changes) {
switch (change.type) {
case 'create':
await api.putEntry({
entity: address,
attribute: change.attribute,
value: change.value
});
break;
case 'delete':
await api.deleteEntry(change.address);
break;
case 'upsert':
await api.putEntityAttribute(address, change.attribute, change.value);
break;
case 'entry-add':
await api.putEntry({
entity: change.address,
attribute: ATTR_IN,
value: { t: 'Address', c: address }
});
break;
case 'entry-delete': {
const inEntry = $entity?.attr[`~${ATTR_IN}`]?.find((e) => e.entity === change.address);
if (inEntry) {
await api.deleteEntry(inEntry.address);
} else {
console.warn("Couldn't find IN entry for entity %s?!", change.address);
}
break;
}
default:
console.error('Unimplemented AttributeChange', change);
return;
}
}
revalidate();
}
let identities = [address];
function onResolved(ev: CustomEvent<string[]>) {
identities = ev.detail;
dispatch('resolved', ev.detail);
}
async function deleteObject() {
if (confirm(`${$i18n.t('Really delete')} "${identities.join(' | ')}"?`)) {
await api.deleteEntry(address);
dispatch('close');
}
}
const attributeWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries }) => [
{
component: EntryList,
props: {
entries,
columns: 'attribute, value'
}
}
]
}
];
const linkWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries, group }) => [
{
component: EntryList,
props: {
entries,
columns: 'attribute, value',
attributes: group ? $allTypes[group]?.attributes : [] || []
}
}
]
},
{
name: 'Entity List',
icon: 'image',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.filter((e) => e.value.t == 'Address').map((e) => e.value.c),
thumbnails: true
}
}
]
}
];
const taggedWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: false
}
}
]
},
{
name: 'EntityList',
icon: 'image',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: true
}
}
]
}
];
$: entity.subscribe(async (object) => {
if (object && object.listing?.entries.length) {
dbg('Updating visit stats for %o', object);
await api.putEntityAttribute(
object.address,
'LAST_VISITED',
{
t: 'Number',
c: new Date().getTime() / 1000
},
'IMPLICIT'
);
await api.putEntityAttribute(
object.address,
'NUM_VISITED',
{
t: 'Number',
c: (parseInt(String(object.get('NUM_VISITED'))) || 0) + 1
},
'IMPLICIT'
);
}
});
</script>
<div
class="inspect"
class:detail
class:blob={blobHandled}
data-address-multi={($entity?.attr['~IN']?.map((e) => e.entity) || []).join(',')}
>
<header>
<h2>
{#if $entity}
{#key $entity}
<UpObject banner {address} on:resolved={onResolved} on:change={onChange} />
{/key}
{:else}
<Spinner centered />
{/if}
</h2>
</header>
{#if !showAsEntries}
<div class="main-content">
<div class="detail-col">
<div class="blob-viewer">
<BlobViewer {address} {detail} on:handled={(ev) => (blobHandled = ev.detail)} />
</div>
{#if !$error && $entity}
<InspectGroups
{entity}
on:highlighted={(ev) => (highlightedType = ev.detail)}
on:change={() => revalidate()}
/>
<div class="properties">
<NotesEditor {address} on:change={onChange} />
<InspectTypeEditor {entity} on:change={() => revalidate()} />
{#each sortedTypes as [typeAddr, { labels, attributes }]}
<EntryView
entries={($entity?.attributes || []).filter((e) =>
attributes.includes(e.attribute)
)}
widgets={linkWidgets}
on:change={onChange}
highlighted={highlightedType == typeAddr}
title={labels.join(' | ')}
group={typeAddr}
{address}
/>
{/each}
{#if currentUntypedProperties.length > 0}
<EntryView
title={$i18n.t('Other Properties') || ''}
icon="shape-triangle"
widgets={attributeWidgets}
entries={currentUntypedProperties}
on:change={onChange}
{address}
/>
{/if}
{#if currentUntypedLinks.length > 0}
<EntryView
title={$i18n.t('Links') || ''}
icon="right-arrow-circle"
widgets={linkWidgets}
entries={currentUntypedLinks}
on:change={onChange}
{address}
/>
{/if}
{#if !correctlyTagged || !incorrectlyTagged}
<EntryView
title={`${$i18n.t('Members')}`}
icon="link"
widgets={taggedWidgets}
entries={tagged}
on:change={onChange}
{address}
/>
{:else}
<EntryView
title={`${$i18n.t('Typed Members')} (${correctlyTagged.length})`}
icon="link"
widgets={taggedWidgets}
entries={tagged.filter((e) => correctlyTagged?.includes(e.entity))}
on:change={onChange}
{address}
/>
<EntryView
title={`${$i18n.t('Untyped members')} (${incorrectlyTagged.length})`}
icon="unlink"
widgets={taggedWidgets}
entries={tagged.filter((e) => incorrectlyTagged?.includes(e.entity))}
on:change={onChange}
{address}
/>
{/if}
{#if currentBacklinks.length > 0}
<EntryView
title={`${$i18n.t('Referred to')} (${currentBacklinks.length})`}
icon="left-arrow-circle"
entries={currentBacklinks}
on:change={onChange}
{address}
/>
{/if}
{#if $entityInfo?.t === 'Attribute'}
<LabelBorder>
<span slot="header">
<Icon plain name="color" />
{$i18n.t('Used')} ({attributesUsed.length})
</span>
<EntryList columns="entity,value" entries={attributesUsed} orderByValue />
</LabelBorder>
{/if}
</div>
{:else if $error}
<div class="error">
{$error}
</div>
{/if}
</div>
</div>
{:else}
<div class="entries">
<h2>{$i18n.t('Attributes')}</h2>
<EntryList
entries={$entity?.attributes || []}
columns={detail ? 'timestamp, user, provenance, attribute, value' : 'attribute, value'}
on:change={onChange}
/>
<h2>{$i18n.t('Backlinks')}</h2>
<EntryList
entries={$entity?.backlinks || []}
columns={detail ? 'timestamp, user, provenance, entity, attribute' : 'entity, attribute'}
on:change={onChange}
/>
</div>
{/if}
<div class="footer">
<IconButton
name="detail"
title={$i18n.t('Show as entries') || ''}
active={showAsEntries}
on:click={() => (showAsEntries = !showAsEntries)}
/>
</div>
<IconButton
name="trash"
outline
subdued
color="#dc322f"
on:click={deleteObject}
title={$i18n.t('Delete object') || ''}
/>
</div>
<style lang="scss">
header h2 {
margin-bottom: 0;
}
.inspect,
.main-content {
flex: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.properties {
flex: auto;
height: 0; // https://stackoverflow.com/a/14964944
min-height: 12em;
overflow-y: auto;
padding-right: 1rem;
}
@media screen and (min-width: 1600px) {
.inspect.detail {
.main-content {
position: relative;
flex-direction: row;
justify-content: end;
}
&.blob {
.detail-col {
width: 33%;
flex-grow: 0;
}
.blob-viewer {
width: 65%;
height: 100%;
position: absolute;
left: 1%;
top: 0;
}
}
}
}
.main-content .detail-col {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.entries {
flex-grow: 1;
}
.footer {
margin-top: 2rem;
display: flex;
justify-content: end;
}
.error {
color: red;
}
</style>