diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg
new file mode 100644
index 0000000..1e7582f
--- /dev/null
+++ b/src/assets/icons/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/edit.svg b/src/assets/icons/edit.svg
new file mode 100644
index 0000000..ae57ded
--- /dev/null
+++ b/src/assets/icons/edit.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/plus.svg b/src/assets/icons/plus.svg
new file mode 100644
index 0000000..7c1f28d
--- /dev/null
+++ b/src/assets/icons/plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/rotate-ccw.svg b/src/assets/icons/rotate-ccw.svg
new file mode 100644
index 0000000..17445a8
--- /dev/null
+++ b/src/assets/icons/rotate-ccw.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/icons/spinner.svg b/src/assets/icons/spinner.svg
new file mode 100644
index 0000000..eb38f01
--- /dev/null
+++ b/src/assets/icons/spinner.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/icons/x.svg b/src/assets/icons/x.svg
new file mode 100644
index 0000000..1a904dc
--- /dev/null
+++ b/src/assets/icons/x.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/main.css b/src/assets/main.css
index 73a0d1e..ef59ebe 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -6,6 +6,7 @@
--fdp-color-muted: #8b949e;
--fdp-color-link: #005ea8;
--fdp-color-link-hover: #004b86;
+ --fdp-color-link-soft: #2b82c4;
--fdp-color-border: #d9dde2;
--fdp-color-card: #f3f3f3;
--fdp-color-card-badge: #b8d5ef;
@@ -14,6 +15,7 @@
--fdp-color-alert-bg: #f8d7da;
--fdp-color-alert-text: #842029;
--fdp-color-danger: #842029;
+ --fdp-color-danger-soft: #d03545;
--fdp-page-width: 980px;
--fdp-radius-sm: 6px;
@@ -100,8 +102,88 @@ a:hover {
line-height: 1.2;
}
-.resource-title {
+.resource-title-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--fdp-space-3);
margin: 0 0 var(--fdp-space-3);
+}
+
+.resource-edit-link {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--fdp-space-2);
+ flex-shrink: 0;
+ font-size: var(--fdp-font-size-sm);
+ font-weight: 600;
+ color: var(--fdp-color-link);
+ text-decoration: none;
+}
+
+.resource-edit-link:hover {
+ color: var(--fdp-color-link-hover);
+}
+
+.membership-badge {
+ padding: var(--fdp-space-1) var(--fdp-space-3);
+ border: 1px solid var(--fdp-color-muted);
+ border-radius: var(--fdp-radius-pill);
+ color: var(--fdp-color-muted);
+ font-size: var(--fdp-font-size-sm);
+}
+
+.resource-title-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--fdp-space-3);
+ margin-left: auto;
+}
+
+.resource-badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--fdp-space-2);
+}
+
+.owners-badge {
+ position: relative;
+ display: inline-flex;
+}
+
+.owners-badge__trigger {
+ background: none;
+ cursor: pointer;
+}
+
+.owners-badge__tooltip {
+ position: absolute;
+ top: calc(100% + var(--fdp-space-1));
+ right: 0;
+ z-index: 10;
+ width: max-content;
+ max-width: min(20rem, calc(100vw - 2 * var(--fdp-space-4)));
+ padding: var(--fdp-space-2) var(--fdp-space-3);
+ border-radius: var(--fdp-radius-sm);
+ background: var(--fdp-color-text);
+ color: var(--fdp-color-surface);
+ font-size: var(--fdp-font-size-sm);
+}
+
+/* Keep the gap hoverable when moving from the trigger onto the tooltip. */
+.owners-badge__tooltip::before {
+ content: '';
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ height: var(--fdp-space-1);
+}
+
+.resource-title {
+ margin: 0;
font-size: var(--fdp-font-size-2xl);
line-height: 1.15;
}
@@ -199,6 +281,24 @@ a:hover {
padding-left: 1.1rem;
}
+.user-form__required {
+ color: var(--fdp-color-danger);
+}
+
+/* Placeholder for editors the form cannot render yet; disappears as widgets land. */
+.user-form__pending {
+ margin: 0;
+ color: var(--fdp-color-muted);
+ font-style: italic;
+}
+
+/* A sh:node sub-record, set off from the field it belongs to. */
+.user-form__nested {
+ margin-left: var(--fdp-space-4);
+ padding: var(--fdp-space-2) 0 var(--fdp-space-2) var(--fdp-space-4);
+ border-left: 2px solid var(--fdp-color-border);
+}
+
.text-link {
border: none;
background: none;
@@ -467,9 +567,12 @@ pre.raw-content code {
color: var(--fdp-color-text);
}
+/* Matches the bottom margin a
one is not left flush
+ against what follows it. */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--fdp-radius-sm);
+ margin-bottom: var(--fdp-space-4);
}
.alert-danger {
@@ -589,6 +692,84 @@ pre.raw-content code {
padding: var(--fdp-space-4) var(--fdp-space-5);
}
+.saving-overlay {
+ min-width: 0;
+ width: min(28rem, 90vw);
+ border: 0;
+ padding: 0;
+ color: inherit;
+}
+
+.saving-overlay::backdrop {
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.saving-overlay__body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--fdp-space-3);
+ padding: var(--fdp-space-8);
+}
+
+.saving-overlay__spinner {
+ color: var(--fdp-color-link);
+ animation: fdp-spin 1s linear infinite;
+}
+
+.saving-overlay__icon {
+ display: grid;
+ place-items: center;
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: 50%;
+}
+
+.saving-overlay__message {
+ margin: 0;
+ font-size: var(--fdp-font-size-xl);
+ font-weight: 600;
+}
+
+.saving-overlay--saved .saving-overlay__icon {
+ background: #d1e7dd;
+ color: #0f5132;
+}
+
+.saving-overlay--saved .saving-overlay__message {
+ color: #0f5132;
+}
+
+/* Matches the one-second confirmation pause in ResourceEditView. */
+.saving-overlay--saved,
+.saving-overlay--saved::backdrop {
+ animation: saving-confirmation-exit 1s ease-in forwards;
+}
+
+@keyframes saving-confirmation-exit {
+ 0%,
+ 75% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .saving-overlay--saved,
+ .saving-overlay--saved::backdrop,
+ .saving-overlay__spinner {
+ animation: none;
+ }
+}
+
+@keyframes fdp-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.about-table {
width: 100%;
border-collapse: collapse;
@@ -1169,7 +1350,45 @@ pre.raw-content code {
}
.user-form__group {
- margin-bottom: 1.5rem;
+ margin-bottom: var(--fdp-space-8);
+}
+
+.user-form__fields {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+
+fieldset.user-form__group {
+ border: 0;
+ padding: 0;
+ margin-inline: 0;
+ min-width: 0;
+}
+
+.user-form__group legend,
+.user-form__label {
+ padding: 0;
+ font-size: var(--fdp-font-size-sm);
+ font-weight: 600;
+ margin-bottom: var(--fdp-space-1);
+}
+
+.user-form__visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.user-form__hint {
+ margin: 0 0 var(--fdp-space-1);
+ font-size: var(--fdp-font-size-sm);
+ color: var(--fdp-color-danger);
}
.user-form__group--error input,
@@ -1190,21 +1409,163 @@ pre.raw-content code {
margin-bottom: var(--fdp-space-1);
}
+/* One field value with its associated controls. */
+.user-form__value {
+ display: flex;
+ align-items: center;
+ gap: var(--fdp-space-3);
+}
+
+/* The row carries the underline, so it runs beneath the Restore and Remove controls the way a
+ date field's own calendar button sits inside its border. A record's row holds a nested form
+ rather than a control of its own, so :has() keeps the line off it. */
+.user-form__value:has(> input, > textarea) {
+ border-bottom: 2px solid #ababab;
+}
+
+.user-form__group .user-form__value > input,
+.user-form__group .user-form__value > textarea {
+ border-bottom: none;
+}
+
+.user-form__value--changed:has(> input, > textarea),
+.user-form__value:focus-within {
+ border-bottom-color: var(--fdp-color-link);
+}
+
+/* A multi-row textarea or a nested record keeps its buttons at the top, not centred
+ against content taller than a single line. */
+.user-form__value--top {
+ align-items: flex-start;
+}
+
+.user-form__value + .user-form__value {
+ margin-top: var(--fdp-space-3);
+}
+
+/* Repeatable values read as a list; the marker needs the item itself, so the flex row sits inside. */
+.user-form__values {
+ margin: 0;
+}
+
+ul.user-form__values {
+ padding-left: var(--fdp-space-8);
+}
+
+.user-form__values > * + * {
+ margin-top: var(--fdp-space-3);
+}
+
+.user-form__value > .user-form__nested {
+ flex: 1;
+}
+
+.user-form__zone {
+ flex-shrink: 0;
+ align-self: center;
+ color: var(--fdp-color-muted);
+ font-size: var(--fdp-font-size-sm);
+}
+
+/* Fixed-width digits, so dates in consecutive fields line up. */
+.user-form__group input[type='date'],
+.user-form__group input[type='datetime-local'] {
+ font-variant-numeric: tabular-nums;
+}
+
+.user-form__remove {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ border: none;
+ background: none;
+ padding: var(--fdp-space-1);
+ color: var(--fdp-color-danger-soft);
+ cursor: pointer;
+ position: relative;
+}
+
+.user-form__remove:hover {
+ color: var(--fdp-color-danger);
+}
+
+.user-form__restore {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ border: none;
+ background: none;
+ padding: var(--fdp-space-1);
+ color: var(--fdp-color-link-soft);
+ cursor: pointer;
+ position: relative;
+}
+
+/* Centred under its control, unlike a title attribute, which the browser places by the pointer. */
+.user-form__tip {
+ position: absolute;
+ top: calc(100% + var(--fdp-space-1));
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10;
+ width: max-content;
+ padding: var(--fdp-space-1) var(--fdp-space-2);
+ border-radius: var(--fdp-radius-sm);
+ background: var(--fdp-color-text);
+ color: var(--fdp-color-surface);
+ font-size: var(--fdp-font-size-sm);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.1s ease-in;
+}
+
+.user-form__restore:hover .user-form__tip,
+.user-form__restore:focus-visible .user-form__tip,
+.user-form__remove:hover .user-form__tip,
+.user-form__remove:focus-visible .user-form__tip {
+ opacity: 1;
+}
+
+.user-form__restore:hover {
+ color: var(--fdp-color-link);
+}
+
+.user-form__add {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--fdp-space-2);
+ margin-top: var(--fdp-space-3);
+ text-decoration: none;
+ font-weight: 400;
+}
+
+/* Puts the + where the bullets are, so Add reads as one more item, for a bulleted list only:
+ a single-valued field has no such indent to match. */
+ul.user-form__values ~ .user-form__add {
+ margin-left: var(--fdp-space-2);
+}
+
+.user-form__group textarea {
+ resize: vertical;
+}
+
.user-form__group input,
-.user-form__group select {
+.user-form__group select,
+.user-form__group textarea {
width: 100%;
box-sizing: border-box;
border: none;
border-bottom: 2px solid #ababab;
outline: none;
- padding: var(--fdp-space-1) 0;
+ padding: var(--fdp-space-1) 0 var(--fdp-space-2);
font-size: 1rem;
background: none;
color: var(--fdp-color-text);
}
.user-form__group input:focus,
-.user-form__group select:focus {
+.user-form__group select:focus,
+.user-form__group textarea:focus {
border-bottom-color: var(--fdp-color-link);
}
@@ -1231,3 +1592,17 @@ pre.raw-content code {
opacity: 0.6;
cursor: not-allowed;
}
+
+.user-form__btn--secondary {
+ background: var(--fdp-color-surface);
+ color: var(--fdp-color-link);
+ border: 1px solid var(--fdp-color-link);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+}
+
+.user-form__btn--secondary:hover:not(:disabled) {
+ background: #f3f8fd;
+ color: var(--fdp-color-link-hover);
+}
diff --git a/src/components/DateInput.vue b/src/components/DateInput.vue
new file mode 100644
index 0000000..b85007c
--- /dev/null
+++ b/src/components/DateInput.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
diff --git a/src/components/OwnersBadge.vue b/src/components/OwnersBadge.vue
new file mode 100644
index 0000000..51157ec
--- /dev/null
+++ b/src/components/OwnersBadge.vue
@@ -0,0 +1,60 @@
+
+
+
+
+ Owned by {{ names[0] }}
+
+
+
+
+
+ {{ description }}
+
+
+
diff --git a/src/components/RawContentPanel.vue b/src/components/RawContentPanel.vue
new file mode 100644
index 0000000..f95dfcb
--- /dev/null
+++ b/src/components/RawContentPanel.vue
@@ -0,0 +1,55 @@
+
+
+
+
+ {{ message }}
+
+
+ {{ text }}
+
+
+
+
diff --git a/src/components/ShapeFormFields.vue b/src/components/ShapeFormFields.vue
new file mode 100644
index 0000000..6028d45
--- /dev/null
+++ b/src/components/ShapeFormFields.vue
@@ -0,0 +1,401 @@
+
+
+
+
+
+ {{ fieldLabel(row.field)
+ }}*
+
+
+ At least {{ row.field.minCount }}
+ {{ row.field.minCount === 1 ? 'value is' : 'values are' }} required.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dateZoneLabel(entry.value) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ compactUri(row.field.editor) }} is not built yet ({{ cardinality(row.field) }})
+
+
+
+
+ {{ requiredHintText(row) }}
+
+
+ {{ invalidUriHintText(row) }}
+
+
+
+
+
+
diff --git a/src/composables/fdpApi.ts b/src/composables/fdpApi.ts
index f8e2d34..a135447 100644
--- a/src/composables/fdpApi.ts
+++ b/src/composables/fdpApi.ts
@@ -1,5 +1,78 @@
import { bindOperation, type OperationBinding } from './apiDocs'
-import { authHeaders, request } from './fetchUtils'
+import { authHeaders, request, withDeadline } from './fetchUtils'
+
+/** Identifies a typed resource; null explicitly identifies the FDP root. */
+export type ResourceIdentifier = { resourceType: string; id: string } | null
+
+// Fields used from the backend's member, meta and resource-definition responses.
+export type ResourceMember = {
+ user: { uuid: string; firstName: string; lastName: string }
+ membership: {
+ name: string
+ permissions: { code: string }[]
+ }
+}
+
+export type ResourceMeta = {
+ member: { membership: ResourceMember['membership'] | null } | null
+}
+
+type ResourceDefinition = {
+ uuid: string
+ name: string
+ urlPrefix: string
+}
+
+/** Reads resource definitions through their advertised operation. */
+async function fetchResourceDefinitions(signal?: AbortSignal): Promise
{
+ const { url, method } = await bindOperation('getResourceDefinitions')
+ signal?.throwIfAborted()
+ const response = await request(url, {
+ method,
+ headers: { Accept: 'application/json' },
+ ...(signal ? { signal } : {}),
+ })
+ return response.json() as Promise
+}
+
+// Share concurrent root lookups (meta and members), but don't retain definitions across visits.
+let rootDefinitionRequest: Promise | null = null
+
+function fetchRootDefinition(signal?: AbortSignal): Promise {
+ const load = () =>
+ fetchResourceDefinitions(signal).then((definitions) => {
+ const root = definitions.find((definition) => definition.urlPrefix === '')
+ if (!root) throw new Error('The FDP has no root resource definition')
+ return root
+ })
+ // A save owns its cancellable lookup; it must not cancel a shared meta/members request.
+ if (signal) return load()
+ rootDefinitionRequest ??= load().finally(() => {
+ rootDefinitionRequest = null
+ })
+ return rootDefinitionRequest
+}
+
+/**
+ * Selects an operation ID from the capitalized resource prefix or root definition name.
+ * bindOperation resolves its endpoint URL from the API docs.
+ */
+export async function getResourceOperation(
+ resource: ResourceIdentifier,
+ action: 'meta' | 'members' | 'put',
+ signal?: AbortSignal,
+) {
+ const prefix = action === 'put' ? 'put' : 'get'
+ const suffix = action === 'meta' ? 'Meta' : action === 'members' ? 'Members' : ''
+ if (resource === null) {
+ const root = await fetchRootDefinition(signal)
+ return { operationId: `${prefix}${root.name}${suffix}` }
+ }
+
+ const { resourceType, id } = resource
+ const resourceName = resourceType.charAt(0).toUpperCase() + resourceType.slice(1)
+ return { operationId: `${prefix}${resourceName}${suffix}`, pathParams: { uuid: id } }
+}
/**
* For /users/current, the signed-in user's profile is edited via current-user operations.
@@ -45,6 +118,76 @@ export async function deleteUser(uuid: string): Promise {
await request(url, { method })
}
+/**
+ * Fetches a resource's meta; pass null for the root.
+ * ResourceMeta describes only the membership fields currently used by the client.
+ */
+export async function fetchMeta(
+ resource: ResourceIdentifier,
+ signal?: AbortSignal,
+): Promise {
+ const { operationId, pathParams } = await getResourceOperation(resource, 'meta')
+ const { url, method } = await bindOperation(operationId, pathParams)
+ signal?.throwIfAborted()
+ const response = await request(url, {
+ method,
+ headers: { Accept: 'application/json' },
+ ...(signal ? { signal } : {}),
+ })
+ return response.json() as Promise
+}
+
+/** Fetches the members of a resource, each with their user and membership; pass null for the root. */
+export async function fetchMembers(resource: ResourceIdentifier): Promise {
+ const { operationId, pathParams } = await getResourceOperation(resource, 'members')
+ const { url, method } = await bindOperation(operationId, pathParams)
+ const response = await request(url, { method, headers: { Accept: 'application/json' } })
+ return response.json() as Promise
+}
+
+export class ResourceSaveError extends Error {
+ constructor(
+ readonly status: number,
+ readonly body: string,
+ ) {
+ super(body || `HTTP ${status}`)
+ this.name = 'ResourceSaveError'
+ }
+}
+
+/**
+ * Saves the resource as Turtle, preserving error response bodies for display.
+ * The timeout covers endpoint discovery, the PUT, and reading error responses.
+ */
+export async function putResource(
+ resource: ResourceIdentifier,
+ turtle: string,
+ timeoutMs = 60_000,
+): Promise {
+ await withDeadline(
+ timeoutMs,
+ 'The save request timed out. Check the resource before retrying.',
+ async (signal) => {
+ // Passing the signal takes a private lookup (see fetchRootDefinition), so a save timing out
+ // cannot cancel the shared one a concurrent read may be awaiting.
+ const { operationId, pathParams } = await getResourceOperation(resource, 'put', signal)
+ const { url, method } = await bindOperation(operationId, pathParams)
+ signal.throwIfAborted()
+ const response = await fetch(url, {
+ method,
+ headers: authHeaders({ 'Content-Type': 'text/turtle' }),
+ body: turtle,
+ signal,
+ })
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ signal.throwIfAborted()
+ throw new ResourceSaveError(response.status, body)
+ }
+ },
+ )
+}
+
/** Fetches a single user's profile. */
export async function fetchUser(uuid?: string): Promise {
const { url } = await bindUserOperation('getUserCurrent', 'getUser', uuid)
diff --git a/src/composables/fetchUtils.ts b/src/composables/fetchUtils.ts
index 86ea982..70a6548 100644
--- a/src/composables/fetchUtils.ts
+++ b/src/composables/fetchUtils.ts
@@ -10,6 +10,28 @@ export function authHeaders(headers: Record = {}): Record(
+ timeoutMs: number,
+ message: string,
+ run: (signal: AbortSignal) => Promise,
+): Promise {
+ const controller = new AbortController()
+ let timer: ReturnType | undefined
+ try {
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ const error = new Error(message)
+ controller.abort(error)
+ reject(error)
+ }, timeoutMs)
+ })
+ return await Promise.race([run(controller.signal), timeout])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
/** Performs a fetch with the bearer token attached, throwing a basic error on non-2xx responses. */
export async function request(
url: string,
@@ -17,7 +39,14 @@ export async function request(
): Promise {
const { headers, ...rest } = init
const response = await fetch(url, { ...rest, headers: authHeaders(headers) })
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ if (!response.ok) {
+ try {
+ await response.body?.cancel()
+ } catch {
+ // Releasing the unread body can fail; preserve the original HTTP status.
+ }
+ throw new Error(`HTTP ${response.status}`)
+ }
return response
}
diff --git a/src/composables/formUtils.ts b/src/composables/formUtils.ts
index 28d6676..794d817 100644
--- a/src/composables/formUtils.ts
+++ b/src/composables/formUtils.ts
@@ -9,3 +9,86 @@ const EMAIL_PATTERN = new RegExp(`^${LOCAL_PART}@${DOMAIN_LABEL}(?:\\.${DOMAIN_L
export function isValidEmail(v: string): boolean {
return EMAIL_PATTERN.test(v)
}
+
+const DATE = String.raw`\d{4}-\d{2}-\d{2}`
+const TIME = String.raw`(?:[01]\d|2[0-3]):[0-5]\d`
+const ZONE = String.raw`Z|[+-](?:0\d|1[0-3]):[0-5]\d|[+-]14:00`
+const CALENDAR_DATE = new RegExp(`^${DATE}$`)
+const PICKER_DATE_TIME_PATTERN = new RegExp(
+ String.raw`^(${DATE})T(${TIME})(?::([0-5]\d)(?:\.\d+)?)?(${ZONE})?$`,
+)
+const PICKER_DATE_PATTERN = new RegExp(`^(${DATE})(${ZONE})?$`)
+
+/** Checks calendar dates supported by this picker: four-digit years after year zero. */
+function isCalendarDate(value: string): boolean {
+ if (!CALENDAR_DATE.test(value)) return false
+ const [year, month, day] = value.split('-').map(Number) as [number, number, number]
+ if (year === 0 || month < 1 || month > 12) return false
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+ return day >= 1 && day <= days[month - 1]!
+}
+
+/** Formats a local clock time, rejecting years the picker cannot represent. */
+function localInputValue(date: Date): string | null {
+ // Shift by the local offset so UTC formatting produces the local clock time.
+ const result = new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString()
+ return isCalendarDate(result.slice(0, 10)) ? result.slice(0, 19) : null
+}
+
+/**
+ * Converts zoned date-times to local time; leaves offset-free times as written.
+ * Returns null for values the native picker cannot display.
+ */
+export function toDateTimeInputValue(lexical: string): string | null {
+ if (lexical === '') return ''
+ const match = PICKER_DATE_TIME_PATTERN.exec(lexical)
+ if (!match) return null
+ const [, date, time, seconds, zone] = match
+ if (!isCalendarDate(date!)) return null
+ // The input uses whole seconds, so omit any fractional part.
+ const asStored = `${date}T${time}${seconds ? `:${seconds}` : ''}`
+ if (zone === undefined) return asStored
+ return localInputValue(new Date(parseDateTimeForPicker(lexical)))
+}
+
+/**
+ * Adds the local UTC offset to an edited date-time. Times in a daylight-saving gap
+ * move forward; invalid input leaves the previous value unchanged.
+ */
+export function fromDateTimeInputValue(inputValue: string, previous: string): string {
+ if (inputValue === '') return ''
+ if (toDateTimeInputValue(inputValue) === null) return previous
+ const date = new Date(inputValue)
+ const local = localInputValue(date)!
+ const offset = -date.getTimezoneOffset()
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0')
+ const minutes = String(Math.abs(offset) % 60).padStart(2, '0')
+ return `${local}${offset < 0 ? '-' : '+'}${hours}:${minutes}`
+}
+
+/** Returns the calendar date alone; the date input has nowhere to show its optional timezone. */
+export function toDateInputValue(lexical: string): string | null {
+ if (lexical === '') return ''
+ const match = PICKER_DATE_PATTERN.exec(lexical)
+ return match && isCalendarDate(match[1]!) ? match[1]! : null
+}
+
+/** Keeps the previous offset when editing an xsd:date. */
+export function fromDateInputValue(inputValue: string, previous: string): string {
+ if (inputValue === '') return ''
+ if (!isCalendarDate(inputValue)) return previous
+ return inputValue + (PICKER_DATE_PATTERN.exec(previous)?.[2] ?? '')
+}
+
+/** Describes an xsd:date offset beside its timezone-free native input. */
+export function dateZoneLabel(lexical: string): string {
+ const zone = PICKER_DATE_PATTERN.exec(lexical)?.[2]
+ if (!zone) return ''
+ return zone === 'Z' ? 'UTC' : `UTC${zone}`
+}
+
+/** Date.parse uses millisecond precision; discard further digits for the picker. */
+function parseDateTimeForPicker(lexical: string): number {
+ return Date.parse(lexical.replace(/(\.\d{3})\d+/, '$1'))
+}
diff --git a/src/composables/rdfUtils.ts b/src/composables/rdfUtils.ts
index c38af5a..588df29 100644
--- a/src/composables/rdfUtils.ts
+++ b/src/composables/rdfUtils.ts
@@ -1,4 +1,4 @@
-import { Store, Parser, DataFactory } from 'n3'
+import { Store, Parser, Writer, DataFactory } from 'n3'
import type { Literal, Term } from 'n3'
import {
DCT_TITLE,
@@ -25,20 +25,14 @@ import {
SIO_IS_ABOUT,
SIO_IS_RELATED_TO,
DASH_URI_VIEWER,
- DASH_VIEWER,
SHACL_IRI,
- SHACL_NODE_SHAPE,
- SHACL_TARGET_CLASS,
- SHACL_PROPERTY,
- SHACL_NODE_KIND,
- SHACL_PATH,
- SHACL_NAME,
- SHACL_ORDER,
prefixes,
XSD_DATE,
XSD_DATETIME,
} from './vocabularies'
import { predicateLabel, metadataPredicatePriority } from './shaclFallback'
+// Keep this type-only: shaclUtils imports runtime helpers from this module.
+import type { ShapeProperty } from './shaclUtils'
import { isInternalUri } from './urlUtils'
// --- Types ---
@@ -62,14 +56,6 @@ export type MetadataRow = {
blankNodes?: BlankNodeProperty[][]
}
-type ShapeProperty = {
- path: string
- label: string | null
- order: number
- viewer: string | null
- nodeKind: string | null
-}
-
// --- Low-level helpers ---
function formatDate(isoString: string): string {
@@ -121,11 +107,13 @@ export function compactUri(uri: string): string {
/** Returns the first literal value for a subject/predicate pair. */
export function getFirstLiteral(
store: Store,
- subjectUri: string,
+ subject: Term | string,
predicate: string,
): string | null {
+ // Accept a Term so blank-node subjects, such as a SHACL property shape, can be read too.
+ const subjectTerm = typeof subject === 'string' ? DataFactory.namedNode(subject) : subject
const obj = store
- .getObjects(DataFactory.namedNode(subjectUri), DataFactory.namedNode(predicate), null)
+ .getObjects(subjectTerm, DataFactory.namedNode(predicate), null)
.find((o) => o.termType === 'Literal')
return obj ? formatLiteralValue(obj as Literal) : null
}
@@ -141,6 +129,16 @@ export function getNodeRefs(store: Store, subjectUri: string, predicate: string)
.map((obj) => (obj.termType === 'BlankNode' ? `_:${obj.value}` : obj.value))
}
+/**
+ * Returns all object terms without display formatting, preserving RDF term types,
+ * datatypes, and language tags for editing.
+ */
+export function getObjectTerms(store: Store, subject: Term | string, predicate: string): Term[] {
+ // Accept a Term so nested records with blank-node subjects can be read too.
+ const subjectTerm = typeof subject === 'string' ? DataFactory.namedNode(subject) : subject
+ return store.getObjects(subjectTerm, DataFactory.namedNode(predicate), null)
+}
+
/**
* Returns a human-readable label for a URI, checking dct:title, rdfs:label, and foaf:name;
* falls back to the last URI path segment.
@@ -164,6 +162,15 @@ export function parseTurtle(turtle: string): Store {
return new Store(new Parser().parse(turtle))
}
+/** Serializes the store as Turtle using full IRIs rather than prefixes. */
+export function serializeTurtle(store: Store): Promise {
+ return new Promise((resolve, reject) => {
+ const writer = new Writer({ format: 'text/turtle' })
+ writer.addQuads(store.getQuads(null, null, null, null))
+ writer.end((error, result) => (error ? reject(error) : resolve(result)))
+ })
+}
+
// --- Primary subject detection ---
function subjectUriCandidates(preferredUri: string): string[] {
@@ -338,95 +345,6 @@ export function getBreadcrumbs(
return items
}
-// --- SHACL/DASH shape properties ---
-
-/** Like getFirstLiteral but takes an N3 Term as subject; used when iterating SHACL shape nodes. */
-function getObjectLiteral(store: Store, subject: Term, predicate: string): string | null {
- const obj = store
- .getObjects(subject, DataFactory.namedNode(predicate), null)
- .find((o) => o.termType === 'Literal')
- return obj ? formatLiteralValue(obj as Literal) : null
-}
-
-/** Like getNodeRefs but takes an N3 Term as subject and returns only the first named node value. */
-function getObjectNamedNode(store: Store, subject: Term, predicate: string): string | null {
- return (
- store
- .getObjects(subject, DataFactory.namedNode(predicate), null)
- .find((o) => o.termType === 'NamedNode')?.value ?? null
- )
-}
-
-/**
- * Reads a SHACL property shape node into a ShapeProperty struct.
- * Returns null if the node has no shacl:path.
- * shacl:order defaults to MAX_SAFE_INTEGER when absent, so unordered properties sort last.
- */
-function readShapeProperty(shapeGraph: Store, propTerm: Term): ShapeProperty | null {
- const path = getObjectNamedNode(shapeGraph, propTerm, SHACL_PATH)
- if (!path) return null
-
- const label = getObjectLiteral(shapeGraph, propTerm, SHACL_NAME)
- const orderStr = getObjectLiteral(shapeGraph, propTerm, SHACL_ORDER)
- const order = orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER
- const viewer = getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER)
- const nodeKind = getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND)
-
- return { path, label, order, viewer, nodeKind }
-}
-
-/**
- * Builds a map of shacl:path -> ShapeProperty for the current resource's RDF types.
- * Searches all provided shape graphs for NodeShapes whose shacl:targetClass matches one of
- * the resource's types. When multiple shapes define the same path, the lower shacl:order wins;
- * missing fields are filled from the superseded entry.
- */
-function getShapePropertyMap(
- resourceStore: Store,
- subjectUri: string | null,
- shapeGraphs: Store[],
-): Map {
- const map = new Map()
- if (!subjectUri) return map
-
- const currentTypes = new Set(getNodeRefs(resourceStore, subjectUri, RDF_TYPE))
- if (currentTypes.size === 0) return map
-
- for (const shapeGraph of shapeGraphs) {
- for (const subject of shapeGraph.getSubjects(
- DataFactory.namedNode(RDF_TYPE),
- DataFactory.namedNode(SHACL_NODE_SHAPE),
- null,
- )) {
- if (subject.termType !== 'NamedNode') continue
- const targetClasses = getNodeRefs(shapeGraph, subject.value, SHACL_TARGET_CLASS)
- if (!targetClasses.some((tc) => currentTypes.has(tc))) continue
-
- for (const propTerm of shapeGraph.getObjects(
- subject,
- DataFactory.namedNode(SHACL_PROPERTY),
- null,
- )) {
- const prop = readShapeProperty(shapeGraph, propTerm)
- if (!prop) continue
-
- // Lower order wins; if the new entry lacks a field, preserve it from the superseded one.
- const existing = map.get(prop.path)
- if (!existing || prop.order < existing.order) {
- map.set(prop.path, {
- path: prop.path,
- label: prop.label ?? existing?.label ?? null,
- order: prop.order,
- viewer: prop.viewer ?? existing?.viewer ?? null,
- nodeKind: prop.nodeKind ?? existing?.nodeKind ?? null,
- })
- }
- }
- }
- }
- return map
-}
-
// --- Metadata rows ---
const embeddedNodeSkipList = new Set([RDF_TYPE, SIO_IS_ABOUT, SIO_IS_RELATED_TO])
@@ -598,7 +516,7 @@ function buildRows(
store: Store,
uri: string,
predicates: string[],
- shapePropertyMap: Map,
+ shapePropertyMap: ReadonlyMap,
): MetadataRow[] {
return predicates
.map((p) => {
@@ -616,12 +534,11 @@ function buildRows(
export function getMetadataRows(
store: Store,
subjectUri: string | null,
- shapeGraphs: Store[],
+ shapePropertyMap: ReadonlyMap,
): { rows: MetadataRow[]; unknownRows: MetadataRow[] } {
if (!subjectUri) return { rows: [], unknownRows: [] }
const allPredicates = getFilteredPredicates(store, subjectUri)
- const shapePropertyMap = getShapePropertyMap(store, subjectUri, shapeGraphs)
if (shapePropertyMap.size === 0) {
const prioritySet = new Set(metadataPredicatePriority)
diff --git a/src/composables/shaclFallback.ts b/src/composables/shaclFallback.ts
index 8c23279..cdcc0d0 100644
--- a/src/composables/shaclFallback.ts
+++ b/src/composables/shaclFallback.ts
@@ -1,4 +1,6 @@
import {
+ DCT_TITLE,
+ DCT_DESCRIPTION,
DCT_CONFORMS_TO,
DCT_LANGUAGE,
DCT_LICENSE,
@@ -37,10 +39,12 @@ import {
DCAT_LANDING_PAGE,
FOAF_NAME,
FOAF_HOMEPAGE,
+ FOAF_HOME_PAGE,
DCAT_CONTACT_POINT,
DCAT_KEYWORD,
DCAT_ACCESS_URL,
DCAT_DOWNLOAD_URL,
+ DCAT_FORMAT,
DCAT_MEDIA_TYPE,
DCAT_BYTE_SIZE,
DCAT_SERVES_DATASET,
@@ -59,6 +63,9 @@ import { compactUri } from './rdfUtils'
// Human-readable labels for known predicates.
// Unknown predicates fall back to their compacted URI.
const labelMap: Record = {
+ // The metadata table renders title and description outside the rows, but a form labels them.
+ [DCT_TITLE]: 'Title',
+ [DCT_DESCRIPTION]: 'Description',
[DCT_CONFORMS_TO]: 'Conforms to',
[DCT_LANGUAGE]: 'Language',
[DCT_LICENSE]: 'License',
@@ -97,10 +104,12 @@ const labelMap: Record = {
[DCAT_LANDING_PAGE]: 'Landing page',
[FOAF_NAME]: 'Name',
[FOAF_HOMEPAGE]: 'Homepage',
+ [FOAF_HOME_PAGE]: 'Homepage',
[DCAT_CONTACT_POINT]: 'Contact point',
[DCAT_KEYWORD]: 'Keyword',
[DCAT_ACCESS_URL]: 'Access URL',
[DCAT_DOWNLOAD_URL]: 'Download URL',
+ [DCAT_FORMAT]: 'Format',
[DCAT_MEDIA_TYPE]: 'Media type',
[DCAT_BYTE_SIZE]: 'Byte size',
[DCAT_SERVES_DATASET]: 'Serves dataset',
diff --git a/src/composables/shaclUtils.ts b/src/composables/shaclUtils.ts
new file mode 100644
index 0000000..cf5da99
--- /dev/null
+++ b/src/composables/shaclUtils.ts
@@ -0,0 +1,210 @@
+import { DataFactory } from 'n3'
+import type { Store, Term } from 'n3'
+import {
+ DASH_EDITOR,
+ DASH_VIEWER,
+ RDF_TYPE,
+ SHACL_MAX_COUNT,
+ SHACL_MIN_COUNT,
+ SHACL_DATATYPE,
+ SHACL_NAME,
+ SHACL_NODE,
+ SHACL_NODE_KIND,
+ SHACL_NODE_SHAPE,
+ SHACL_ORDER,
+ SHACL_PATH,
+ SHACL_PROPERTY,
+ SHACL_TARGET_CLASS,
+} from './vocabularies'
+import { getFirstLiteral, getNodeRefs } from './rdfUtils'
+
+/** SHACL constraints and UI hints for one property path. */
+export type ShapeProperty = {
+ path: string
+ label: string | null
+ order: number
+ viewer: string | null
+ editor: string | null
+ nodeKind: string | null
+ datatype: string | null
+ /** IRI of the nested shape referenced by sh:node. */
+ node: string | null
+ /** Resolved properties of the referenced shape; empty when unavailable or at the depth limit. */
+ nested: ReadonlyMap
+ minCount: number | null
+ maxCount: number | null
+}
+
+/** An editor-enabled property with recursively filtered and ordered nested fields. */
+export type EditableField = Omit & {
+ editor: string
+ nested: EditableField[]
+}
+
+/** Returns the first named-node object's IRI. */
+function getObjectNamedNode(store: Store, subject: Term, predicate: string): string | null {
+ return (
+ store
+ .getObjects(subject, DataFactory.namedNode(predicate), null)
+ .find((o) => o.termType === 'NamedNode')?.value ?? null
+ )
+}
+
+/** Parses a SHACL cardinality literal; absent or unparseable means no constraint. */
+function readCount(shapeGraph: Store, propTerm: Term, predicate: string): number | null {
+ const raw = getFirstLiteral(shapeGraph, propTerm, predicate)
+ if (raw === null) return null
+ const count = parseInt(raw, 10)
+ return Number.isNaN(count) ? null : count
+}
+
+/**
+ * Reads a SHACL property shape, returning null unless sh:path is a named node.
+ * sh:order defaults to MAX_SAFE_INTEGER when absent, so unordered properties sort last.
+ */
+function readShapeProperty(shapeGraph: Store, propTerm: Term): ShapeProperty | null {
+ const path = getObjectNamedNode(shapeGraph, propTerm, SHACL_PATH)
+ if (!path) return null
+
+ const orderStr = getFirstLiteral(shapeGraph, propTerm, SHACL_ORDER)
+
+ return {
+ path,
+ label: getFirstLiteral(shapeGraph, propTerm, SHACL_NAME),
+ order: orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER,
+ viewer: getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER),
+ editor: getObjectNamedNode(shapeGraph, propTerm, DASH_EDITOR),
+ nodeKind: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND),
+ datatype: getObjectNamedNode(shapeGraph, propTerm, SHACL_DATATYPE),
+ node: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE),
+ minCount: readCount(shapeGraph, propTerm, SHACL_MIN_COUNT),
+ maxCount: readCount(shapeGraph, propTerm, SHACL_MAX_COUNT),
+ nested: new Map(),
+ }
+}
+
+/** Returns the stricter of two cardinality bounds; null means the shape set no bound. */
+function mergeCardinalityBound(
+ a: number | null,
+ b: number | null,
+ pick: (x: number, y: number) => number,
+) {
+ if (a === null) return b
+ if (b === null) return a
+ return pick(a, b)
+}
+
+/**
+ * Merges shapes for one path using the strictest cardinality bounds.
+ * Each hint comes from the lowest-order shape that declares it;
+ * ties use encounter order.
+ */
+function mergeShapeProperties(properties: ShapeProperty[]): ShapeProperty {
+ return [...properties]
+ .sort((a, b) => a.order - b.order)
+ .reduce((merged, prop) => ({
+ path: merged.path,
+ label: merged.label ?? prop.label,
+ order: merged.order,
+ viewer: merged.viewer ?? prop.viewer,
+ editor: merged.editor ?? prop.editor,
+ nodeKind: merged.nodeKind ?? prop.nodeKind,
+ datatype: merged.datatype ?? prop.datatype,
+ node: merged.node ?? prop.node,
+ minCount: mergeCardinalityBound(merged.minCount, prop.minCount, Math.max),
+ maxCount: mergeCardinalityBound(merged.maxCount, prop.maxCount, Math.min),
+ nested: merged.nested,
+ }))
+}
+
+/** Bounds nested-shape traversal, including cyclic references. */
+const MAX_SHAPE_DEPTH = 2
+
+/** Groups property shapes by path for NodeShapes accepted by the selection callback. */
+function collectByPath(
+ shapeGraphs: Store[],
+ accepts: (shapeGraph: Store, shapeIri: string) => boolean,
+): Map {
+ const byPath = new Map()
+
+ for (const shapeGraph of shapeGraphs) {
+ for (const subject of shapeGraph.getSubjects(
+ DataFactory.namedNode(RDF_TYPE),
+ DataFactory.namedNode(SHACL_NODE_SHAPE),
+ null,
+ )) {
+ if (subject.termType !== 'NamedNode') continue
+ if (!accepts(shapeGraph, subject.value)) continue
+
+ for (const propTerm of shapeGraph.getObjects(
+ subject,
+ DataFactory.namedNode(SHACL_PROPERTY),
+ null,
+ )) {
+ const prop = readShapeProperty(shapeGraph, propTerm)
+ if (!prop) continue
+
+ const collected = byPath.get(prop.path)
+ if (collected) collected.push(prop)
+ else byPath.set(prop.path, [prop])
+ }
+ }
+ }
+
+ return byPath
+}
+
+/** Merges constraints by path, then resolves the selected sh:node recursively up to the depth limit. */
+function buildPropertyMap(
+ shapeGraphs: Store[],
+ accepts: (shapeGraph: Store, shapeIri: string) => boolean,
+ depth: number,
+): Map {
+ return new Map(
+ [...collectByPath(shapeGraphs, accepts)].map(([path, properties]) => {
+ const merged = mergeShapeProperties(properties)
+ const nested =
+ merged.node && depth < MAX_SHAPE_DEPTH
+ ? buildPropertyMap(shapeGraphs, (_graph, iri) => iri === merged.node, depth + 1)
+ : new Map()
+ return [path, { ...merged, nested }]
+ }),
+ )
+}
+
+/**
+ * Builds a map of sh:path -> ShapeProperty for the current resource's RDF types.
+ * Searches all provided shape graphs for NodeShapes whose sh:targetClass matches one of
+ * the resource's types. Shapes that constrain the same path are merged, see
+ * mergeShapeProperties.
+ */
+export function getShapePropertyMap(
+ resourceStore: Store,
+ subjectUri: string | null,
+ shapeGraphs: Store[],
+): Map {
+ if (!subjectUri) return new Map()
+
+ const currentTypes = new Set(getNodeRefs(resourceStore, subjectUri, RDF_TYPE))
+ if (currentTypes.size === 0) return new Map()
+
+ return buildPropertyMap(
+ shapeGraphs,
+ (shapeGraph, shapeIri) =>
+ getNodeRefs(shapeGraph, shapeIri, SHACL_TARGET_CLASS).some((tc) => currentTypes.has(tc)),
+ 0,
+ )
+}
+
+/**
+ * Selects properties with dash:editor and sorts them by sh:order,
+ * recursively including nested fields.
+ */
+export function getEditableFields(
+ shapePropertyMap: ReadonlyMap,
+): EditableField[] {
+ return [...shapePropertyMap.values()]
+ .filter((property): property is ShapeProperty & { editor: string } => property.editor !== null)
+ .sort((a, b) => a.order - b.order)
+ .map((property) => ({ ...property, nested: getEditableFields(property.nested) }))
+}
diff --git a/src/composables/shapeForm.ts b/src/composables/shapeForm.ts
new file mode 100644
index 0000000..1e74df1
--- /dev/null
+++ b/src/composables/shapeForm.ts
@@ -0,0 +1,166 @@
+import type { Store, Term } from 'n3'
+import { markRaw } from 'vue'
+import { getObjectTerms } from './rdfUtils'
+import {
+ DASH_BLANK_NODE_EDITOR,
+ DASH_DATE_PICKER_EDITOR,
+ DASH_DATE_TIME_PICKER_EDITOR,
+ DASH_TEXT_AREA_EDITOR,
+ DASH_TEXT_FIELD_EDITOR,
+ DASH_URI_EDITOR,
+ SHACL_IRI,
+ SHACL_LITERAL,
+} from './vocabularies'
+import { predicateLabel } from './shaclFallback'
+import type { EditableField } from './shaclUtils'
+
+/** Editable text plus the original RDF term, retained unchanged for later persistence. */
+export type TermValue = {
+ value: string
+ originalTerm?: Term
+}
+
+/** Form state keyed by property path, with an array of term or nested-record entries per field. */
+export type NodeValues = { [path: string]: FieldValue[] }
+export type NestedValue = {
+ originalTerm?: Term
+ values: NodeValues
+}
+export type FieldValue = TermValue | NestedValue
+
+export function requiredFieldMessage(
+ field: Pick,
+ label: string,
+): string {
+ const minimum = field.minCount ?? 0
+ return field.maxCount === 1
+ ? `${label} is required.`
+ : `${label} requires at least ${minimum} ${minimum === 1 ? 'value' : 'values'}.`
+}
+
+export function invalidUriMessage(label: string): string {
+ return `${label} must be a valid absolute IRI.`
+}
+
+/**
+ * Renders a nested record only for BlankNodeEditor with resolved nested fields.
+ * sh:node alone can also constrain a URIEditor's target.
+ */
+export function isNestedField(field: EditableField): boolean {
+ return field.editor === DASH_BLANK_NODE_EDITOR && field.nested.length > 0
+}
+
+const SUPPORTED_VALUE_EDITORS = new Set([
+ DASH_TEXT_FIELD_EDITOR,
+ DASH_TEXT_AREA_EDITOR,
+ DASH_URI_EDITOR,
+ DASH_DATE_PICKER_EDITOR,
+ DASH_DATE_TIME_PICKER_EDITOR,
+])
+
+/** Identifies value editors supported for both rendering and saving. */
+export function isSupportedValueEditor(editor: string): boolean {
+ return SUPPORTED_VALUE_EDITORS.has(editor)
+}
+
+/**
+ * Treats URIEditor and sh:IRI fields as IRI inputs that save as named nodes,
+ * even when sh:IRI is paired with a text editor hint.
+ */
+export function isUriField(field: Pick): boolean {
+ return field.editor === DASH_URI_EDITOR || field.nodeKind === SHACL_IRI
+}
+
+/** A blank hint for a field the shape explicitly types as a plain RDF literal. */
+export function isLiteralField(field: Pick): boolean {
+ return field.nodeKind === SHACL_LITERAL
+}
+
+/** Uppercases the first letter without changing intentional capitalization in the rest. */
+function capitalize(text: string): string {
+ return text.charAt(0).toUpperCase() + text.slice(1)
+}
+
+/** Uses the shape name when present, capitalized consistently with predicate-derived labels. */
+export function fieldLabel(field: Pick): string {
+ return field.label !== null ? capitalize(field.label) : predicateLabel(field.path)
+}
+
+/**
+ * Identifies the paths, cardinality, and nesting that determine initial form state.
+ * Presentation changes and fresh field arrays must not reset the user's edits.
+ */
+export function fieldsSignature(fields: EditableField[]): string {
+ return JSON.stringify(
+ [...fields]
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
+ .map((field) => [
+ field.path,
+ field.minCount ?? 0,
+ field.maxCount,
+ isNestedField(field) ? fieldsSignature(field.nested) : null,
+ ]),
+ )
+}
+
+/** Keeps the original term identity separate from the editable lexical value. */
+function toTermValue(term: Term): TermValue {
+ return { value: term.value, originalTerm: markRaw(term) }
+}
+
+/**
+ * Preserves every existing entry. Adds blanks up to at least one or sh:minCount,
+ * capped by sh:maxCount.
+ */
+function entryCount(field: EditableField, present: number): number {
+ const maxCount = field.maxCount ?? Number.POSITIVE_INFINITY
+ return Math.max(present, Math.min(Math.max(field.minCount ?? 0, 1), maxCount))
+}
+
+/** A fresh blank entry per slot, so no two entries share one object. */
+function blankEntries(count: number, blank: () => T): T[] {
+ return Array.from({ length: Math.max(0, count) }, blank)
+}
+
+/** Creates a fresh value or nested record for initialization and Add. */
+export function createEmptyEntry(field: EditableField): FieldValue {
+ return isNestedField(field) ? { values: emptyValues(field.nested) } : { value: '' }
+}
+
+/** Blank state mirroring the field structure, for a record that has no values yet. */
+export function emptyValues(fields: EditableField[]): NodeValues {
+ return Object.fromEntries(
+ fields.map((field) => [
+ field.path,
+ blankEntries(entryCount(field, 0), () => createEmptyEntry(field)),
+ ]),
+ )
+}
+
+/**
+ * Seeds form state from all matching RDF terms, preserving their original values
+ * and adding blank entries according to cardinality. Recurses into nested records.
+ */
+export function seedValues(
+ store: Store,
+ subject: Term | string,
+ fields: EditableField[],
+): NodeValues {
+ return Object.fromEntries(
+ fields.map((field) => {
+ const terms = getObjectTerms(store, subject, field.path)
+ const missing = entryCount(field, terms.length) - terms.length
+
+ if (isNestedField(field)) {
+ const nested = terms.map((term) => ({
+ originalTerm: markRaw(term),
+ values: seedValues(store, term, field.nested),
+ }))
+ return [field.path, [...nested, ...blankEntries(missing, () => createEmptyEntry(field))]]
+ }
+
+ const values = terms.map(toTermValue)
+ return [field.path, [...values, ...blankEntries(missing, () => createEmptyEntry(field))]]
+ }),
+ )
+}
diff --git a/src/composables/shapeGraph.ts b/src/composables/shapeGraph.ts
new file mode 100644
index 0000000..4f28903
--- /dev/null
+++ b/src/composables/shapeGraph.ts
@@ -0,0 +1,228 @@
+import { DataFactory, Store } from 'n3'
+import type { BlankNode, Literal, NamedNode, Term } from 'n3'
+import type { EditableField } from './shaclUtils'
+import {
+ isSupportedValueEditor,
+ isNestedField,
+ isUriField,
+ isLiteralField,
+ fieldLabel,
+ invalidUriMessage,
+ requiredFieldMessage,
+ type NestedValue,
+ type NodeValues,
+ type TermValue,
+} from './shapeForm'
+import { isAbsoluteIri } from './urlUtils'
+import type { ValidationResult } from './validationReport'
+
+const { namedNode, literal, blankNode, quad } = DataFactory
+
+/** A record's own subject: never a literal, so it can hold further triples about itself. */
+type RecordSubject = NamedNode | BlankNode
+type RemovedRecord = { subject: RecordSubject; fields: EditableField[] }
+
+const keepsLeafEntry = (entry: TermValue) => entry.value !== '' || entry.originalTerm?.value === ''
+
+/** Preserve existing records; a new record needs at least one value the editor can write. */
+function keepsNestedEntry(entry: NestedValue, fields: EditableField[]): boolean {
+ if (entry.originalTerm) return true
+ return fields.some((field) => {
+ const entries = entry.values[field.path] ?? []
+ return isNestedField(field)
+ ? (entries as NestedValue[]).some((child) => keepsNestedEntry(child, field.nested))
+ : isSupportedValueEditor(field.editor) && (entries as TermValue[]).some(keepsLeafEntry)
+ })
+}
+
+/** Checks sh:minCount using the distinct RDF terms that saving would keep. */
+export function satisfiesLeafMinCount(field: EditableField, entries: TermValue[]): boolean {
+ const terms: (NamedNode | Literal)[] = []
+ for (const entry of entries) {
+ if (!keepsLeafEntry(entry)) continue
+ const term = termFor(entry, field)
+ if (!terms.some((stored) => stored.equals(term))) terms.push(term)
+ }
+ return terms.length >= (field.minCount ?? 0)
+}
+
+/**
+ * Applies supported form edits to a copy of the graph, preserving literal language and datatype.
+ * An incomplete draft builds freely, so a preview can show work in progress; call
+ * validateResourceGraph before saving.
+ */
+export function buildResourceGraph(
+ store: Store,
+ subjectUri: string,
+ fields: EditableField[],
+ values: NodeValues,
+): Store {
+ const graph = new Store(store.getQuads(null, null, null, null))
+ const subject = namedNode(subjectUri)
+ const removed: RemovedRecord[] = []
+ replaceFields(graph, subject, fields, values, removed)
+ removeUnreferencedRecords(graph, subject, removed)
+ return graph
+}
+
+/**
+ * Local failures for a built graph, in the same format as parsed server results. Reads the
+ * outgoing graph rather than the form, so omitted blanks and duplicate values are already resolved.
+ */
+export function validateResourceGraph(
+ graph: Store,
+ subjectUri: string,
+ fields: EditableField[],
+): ValidationResult[] {
+ const results: ValidationResult[] = []
+ checkFieldConstraints(graph, namedNode(subjectUri), fields, results)
+ return results
+}
+
+/**
+ * Checks required-count and absolute-IRI constraints in the outgoing RDF graph, where omitted
+ * blanks and duplicate values are already resolved.
+ */
+function checkFieldConstraints(
+ store: Store,
+ subject: RecordSubject,
+ fields: EditableField[],
+ results: ValidationResult[],
+ parentLabel = '',
+) {
+ for (const field of fields) {
+ const nested = isNestedField(field)
+ if (!nested && !isSupportedValueEditor(field.editor)) continue
+ const label = parentLabel + fieldLabel(field)
+ const terms = store.getObjects(subject, namedNode(field.path), null)
+ const minimum = field.minCount ?? 0
+ if (terms.length < minimum) {
+ results.push({
+ focusNode: subject.termType === 'NamedNode' ? subject : undefined,
+ path: namedNode(field.path),
+ messages: [requiredFieldMessage(field, label)],
+ })
+ }
+ // A namedNode built from non-absolute text (see termFor) would otherwise be silently
+ // resolved into an unrelated absolute URI by the server's Turtle parser on save.
+ if (isUriField(field) && terms.some((term) => !isAbsoluteIri(term.value))) {
+ results.push({
+ focusNode: subject.termType === 'NamedNode' ? subject : undefined,
+ path: namedNode(field.path),
+ messages: [invalidUriMessage(label)],
+ })
+ }
+ if (nested) {
+ for (const term of terms) {
+ const child = asRecordSubject(term)
+ if (child) checkFieldConstraints(store, child, field.nested, results, `${label}: `)
+ }
+ }
+ }
+}
+
+/** Replaces each field's stored terms with its current form entries, recursing into sh:node records. */
+function replaceFields(
+ store: Store,
+ subject: RecordSubject,
+ fields: EditableField[],
+ values: NodeValues,
+ removed: RemovedRecord[],
+) {
+ for (const field of fields) {
+ const nested = isNestedField(field)
+ // Preserve fields whose editors are not implemented.
+ if (!nested && !isSupportedValueEditor(field.editor)) continue
+
+ const path = namedNode(field.path)
+ const previous = store.getObjects(subject, path, null)
+ store.removeQuads(store.getQuads(subject, path, null, null))
+ const entries = values[field.path] ?? []
+
+ if (nested) {
+ // Compare RDF term type and value, not JavaScript object identity.
+ const kept = new Set()
+ for (const entry of entries as NestedValue[]) {
+ if (!keepsNestedEntry(entry, field.nested)) continue
+ const term = asRecordSubject(entry.originalTerm) ?? blankNode()
+ kept.add(termKey(term))
+ store.addQuad(quad(subject, path, term))
+ replaceFields(store, term, field.nested, entry.values, removed)
+ }
+ // Defer cleanup until every field's final references have been written.
+ for (const term of previous) {
+ const stale = !kept.has(termKey(term)) && asRecordSubject(term)
+ if (stale) removed.push({ subject: stale, fields: field.nested })
+ }
+ } else {
+ for (const entry of entries as TermValue[]) {
+ // Omit blank inputs, but preserve originally empty literals.
+ if (keepsLeafEntry(entry)) {
+ store.addQuad(quad(subject, path, termFor(entry, field)))
+ }
+ }
+ }
+ }
+}
+
+/** Collects removal candidates, retains those reachable from surviving references, and deletes the rest. */
+function removeUnreferencedRecords(store: Store, root: RecordSubject, removed: RemovedRecord[]) {
+ const candidates = new Map()
+ function collect(subject: RecordSubject, fields: EditableField[]) {
+ candidates.set(termKey(subject), subject)
+ for (const field of fields) {
+ if (!isNestedField(field)) continue
+ for (const term of store.getObjects(subject, namedNode(field.path), null)) {
+ const child = asRecordSubject(term)
+ if (child) collect(child, field.nested)
+ }
+ }
+ }
+ for (const record of removed) collect(record.subject, record.fields)
+
+ // Keep records referenced from outside the removal set, and everything they still reference.
+ const pending = [...candidates.values()].filter(
+ (subject) =>
+ subject.equals(root) ||
+ store.getSubjects(null, subject, null).some((source) => !candidates.has(termKey(source))),
+ )
+ const retained = new Set()
+ while (pending.length) {
+ const subject = pending.pop()!
+ const key = termKey(subject)
+ if (retained.has(key)) continue
+ retained.add(key)
+ for (const term of store.getObjects(subject, null, null)) {
+ const child = candidates.get(termKey(term))
+ if (child) pending.push(child)
+ }
+ }
+ for (const [key, subject] of candidates) {
+ if (!retained.has(key)) store.removeQuads(store.getQuads(subject, null, null, null))
+ }
+}
+
+/** sh:node targets are never literals in valid data; falls back rather than assume so. */
+function asRecordSubject(term: Term | undefined): RecordSubject | undefined {
+ return term?.termType === 'NamedNode' || term?.termType === 'BlankNode' ? term : undefined
+}
+
+function termKey(term: Term): string {
+ return `${term.termType}:${term.value}`
+}
+
+/**
+ * Uses IRIs for IRI fields and preserves untouched stored IRIs. Other edited IRIs stay IRIs
+ * when absolute and not constrained to literals. Literals retain their language or datatype.
+ */
+function termFor(entry: TermValue, field: EditableField): NamedNode | Literal {
+ if (isUriField(field)) return namedNode(entry.value)
+ const original = entry.originalTerm
+ if (original?.termType === 'NamedNode') {
+ if (entry.value === original.value) return original
+ const typedAsLiteral = isLiteralField(field) || field.datatype !== null
+ if (!typedAsLiteral && isAbsoluteIri(entry.value)) return namedNode(entry.value)
+ }
+ const kind = original?.termType === 'Literal' ? original.language || original.datatype : null
+ return literal(entry.value, kind ?? (field.datatype ? namedNode(field.datatype) : undefined))
+}
diff --git a/src/composables/urlUtils.ts b/src/composables/urlUtils.ts
index 8c86251..7edebb7 100644
--- a/src/composables/urlUtils.ts
+++ b/src/composables/urlUtils.ts
@@ -41,3 +41,17 @@ export function internalHref(uri: string): string {
// parts.length < 2 means a container URI with no id (e.g. /catalog); no router route exists, fall back to root.
return parts.length >= 2 ? `/${parts[0]}/${parts[1]}` : '/'
}
+
+/** Accepts absolute IRIs that can be written directly as Turtle IRI references. */
+export function isAbsoluteIri(value: string): boolean {
+ // URL accepts and escapes characters that N3 writes verbatim inside <...>. The control
+ // range is matched deliberately: Turtle forbids those characters in an IRI reference.
+ // oxlint-disable-next-line no-control-regex
+ if (/[\u0000-\u0020<>"{}|^`\\]/u.test(value)) return false
+ try {
+ new URL(value)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/src/composables/useMembers.ts b/src/composables/useMembers.ts
new file mode 100644
index 0000000..6b9a702
--- /dev/null
+++ b/src/composables/useMembers.ts
@@ -0,0 +1,56 @@
+import { computed, ref, watch, type Ref } from 'vue'
+import { fetchMembers, type ResourceIdentifier, type ResourceMember } from './fdpApi'
+import { useAuth } from './useAuth'
+
+/**
+ * Names of the owners of the resource (null for the root) other than the current user,
+ * shown to admins only.
+ */
+export function useMembers(resource: Ref) {
+ const { isLoggedIn, isAdmin, user } = useAuth()
+ const loading = ref(false)
+ const error = ref(null)
+ // Whether the owners list was fetched for the current resource, so an empty list is meaningful.
+ const loaded = ref(false)
+ const owners = ref([])
+ const otherOwnerNames = computed(() =>
+ owners.value
+ .filter((owner) => owner.uuid !== user.value?.uuid)
+ .map((owner) => `${owner.firstName} ${owner.lastName}`),
+ )
+
+ // Identifies the newest check, so a slower older one cannot overwrite its result.
+ let generation = 0
+
+ watch(
+ [resource, isLoggedIn, isAdmin],
+ async ([currentResource, loggedIn, admin], _previous, onCleanup) => {
+ const current = ++generation
+ onCleanup(() => {
+ generation++
+ })
+ owners.value = []
+ error.value = null
+ loaded.value = false
+ loading.value = loggedIn && admin
+ if (!loggedIn || !admin) return
+ try {
+ const members = await fetchMembers(currentResource)
+ if (current !== generation) return
+ owners.value = members
+ .filter((member) => member.membership.name === 'Owner')
+ .map((member) => member.user)
+ loaded.value = true
+ } catch (err) {
+ if (current === generation) {
+ error.value = err instanceof Error ? err.message : 'Unable to get resource members.'
+ }
+ } finally {
+ if (current === generation) loading.value = false
+ }
+ },
+ { immediate: true },
+ )
+
+ return { otherOwnerNames, loaded, loading, error }
+}
diff --git a/src/composables/useMeta.ts b/src/composables/useMeta.ts
new file mode 100644
index 0000000..aaad5d7
--- /dev/null
+++ b/src/composables/useMeta.ts
@@ -0,0 +1,98 @@
+import { computed, ref, watch, type Ref } from 'vue'
+import { fetchMeta, getResourceOperation, type ResourceIdentifier } from './fdpApi'
+import { isOperationOffered } from './apiDocs'
+import { withDeadline } from './fetchUtils'
+import { useAuth } from './useAuth'
+
+type EditAccess = {
+ membershipName: string | null
+ hasWrite: boolean
+ operationId: string
+}
+
+const ACCESS_TIMEOUT_MS = 10_000
+
+async function loadEditAccess(resource: ResourceIdentifier): Promise {
+ // The deadline includes shared API discovery, while the signal only aborts the metadata request.
+ const [meta, { operationId }] = await withDeadline(
+ ACCESS_TIMEOUT_MS,
+ 'Checking edit access timed out. Please try again.',
+ (signal) => Promise.all([fetchMeta(resource, signal), getResourceOperation(resource, 'put')]),
+ )
+ const membership = meta.member?.membership
+ return {
+ membershipName: membership?.name ?? null,
+ hasWrite: membership?.permissions.some((permission) => permission.code === 'W') ?? false,
+ operationId,
+ }
+}
+
+function permitsEdit(hasWrite: boolean, operationId: string, admin: boolean): boolean {
+ return (admin || hasWrite) && isOperationOffered(operationId)
+}
+
+/**
+ * The current user's membership on the resource (null for the root) and whether editing is
+ * possible. Admins may edit everything; others need W in their membership on this exact resource.
+ * Editing also requires the resource's meta to load successfully and the backend to advertise a
+ * put operation for it, since permission alone doesn't guarantee the endpoint exists.
+ */
+export function useMeta(resource: Ref) {
+ const { isLoggedIn, isAdmin } = useAuth()
+ const loading = ref(false)
+ const error = ref(null)
+ const membershipName = ref(null)
+ const hasWrite = ref(false)
+ // Only the operation ID is stored; whether it's offered is read reactively below so a later
+ // refreshApiDocs() (e.g. after a resource-definition change) updates canEdit without a re-check.
+ const putOperationId = ref(null)
+ const canEdit = computed(
+ () =>
+ isLoggedIn.value &&
+ putOperationId.value !== null &&
+ permitsEdit(hasWrite.value, putOperationId.value, isAdmin.value),
+ )
+
+ // Identifies the newest check, so a slower older one cannot overwrite its result.
+ let generation = 0
+
+ watch(
+ [resource, isLoggedIn],
+ async ([currentResource, loggedIn], _previous, onCleanup) => {
+ const current = ++generation
+ onCleanup(() => {
+ generation++
+ })
+ membershipName.value = null
+ hasWrite.value = false
+ putOperationId.value = null
+ error.value = null
+ loading.value = loggedIn
+ if (!loggedIn) return
+ try {
+ const access = await loadEditAccess(currentResource)
+ if (current !== generation) return
+ membershipName.value = access.membershipName
+ hasWrite.value = access.hasWrite
+ putOperationId.value = access.operationId
+ } catch (err) {
+ if (current === generation) {
+ error.value = err instanceof Error ? err.message : 'Unable to get resource metadata.'
+ }
+ } finally {
+ if (current === generation) loading.value = false
+ }
+ },
+ { immediate: true },
+ )
+
+ return { membershipName, canEdit, loading, error }
+}
+
+/** Checks route access; request failures propagate so they are not mistaken for denial. */
+export async function canEditResource(resource: ResourceIdentifier): Promise {
+ const { isLoggedIn, isAdmin } = useAuth()
+ if (!isLoggedIn.value) return false
+ const access = await loadEditAccess(resource)
+ return isLoggedIn.value && permitsEdit(access.hasWrite, access.operationId, isAdmin.value)
+}
diff --git a/src/composables/useRawFormat.ts b/src/composables/useRawFormat.ts
index f9e247d..f099656 100644
--- a/src/composables/useRawFormat.ts
+++ b/src/composables/useRawFormat.ts
@@ -1,9 +1,6 @@
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
import { fetchRdf } from './fetchUtils'
-import Prism from 'prismjs'
-import 'prismjs/components/prism-turtle'
-import 'prismjs/components/prism-json'
const formats = [
{ id: 'turtle', label: 'ttl', accept: 'text/turtle', param: 'ttl' },
@@ -15,14 +12,13 @@ type FormatId = (typeof formats)[number]['id']
export { formats, type FormatId }
/**
- * Manages the raw-format panel: toggling between Turtle and JSON-LD, syntax highlighting, and resizing.
+ * Selects raw formats and fetches their content.
* rawTurtle is passed in because it is already loaded by the parent; JSON-LD is fetched on demand.
*/
export function useRawFormat(resourceUri: Ref, rawTurtle: Ref) {
const shownFormat = ref(null)
const extraRawText = ref>({ turtle: null, 'json-ld': null })
const rawLoading = ref(false)
- const rawContentHeight = ref(300)
const shownFormatByUri = new Map()
const extraRawTextByUri = new Map>()
@@ -32,15 +28,9 @@ export function useRawFormat(resourceUri: Ref, rawTurtle: Ref {
- if (!shownFormat.value) return ''
- const text = rawTextFor(shownFormat.value)
- if (!text) return ''
- const lang = shownFormat.value === 'turtle' ? 'turtle' : 'json'
- const grammar = Prism.languages[lang]
- if (!grammar) return text
- return Prism.highlight(text, grammar, lang)
- })
+ const rawContent = computed(() =>
+ shownFormat.value ? (rawTextFor(shownFormat.value) ?? '') : '',
+ )
async function toggleFormat(id: FormatId, accept: string) {
if (shownFormat.value === id) {
@@ -59,23 +49,6 @@ export function useRawFormat(resourceUri: Ref, rawTurtle: Ref {
shownFormatByUri.set(oldUri, shownFormat.value)
@@ -88,9 +61,7 @@ export function useRawFormat(resourceUri: Ref, rawTurtle: Ref(null)
const childSummaries = ref>({})
const parentSummaries = ref>({})
- const shapeGraphs = ref>({})
+
+ // Identifies the newest load, so a slower older one cannot overwrite its result.
+ let generation = 0
/** Fetches and parses the primary resource, populating quads and rawTurtle. */
async function loadResource(uri: string) {
+ const current = ++generation
loading.value = true
error.value = null
quads.value = new Store()
@@ -46,12 +49,15 @@ export function useRdfLoader() {
try {
const rawText = await fetchRdfTurtle(uri)
+ if (current !== generation) return
quads.value = parseTurtle(rawText)
rawTurtle.value = rawText
} catch (err) {
- error.value = err instanceof Error ? err.message : 'Unknown error'
+ if (current === generation) {
+ error.value = err instanceof Error ? err.message : 'Unknown error'
+ }
} finally {
- loading.value = false
+ if (current === generation) loading.value = false
}
}
@@ -82,30 +88,30 @@ export function useRdfLoader() {
}
}
- /** Fetches a profile and calls loadShapeDocument for each of its SHACL artifacts. */
- async function loadProfile(uri: string): Promise {
- if (shapeGraphs.value[uri]) return
-
- try {
- const store = parseTurtle(await fetchRdfTurtle(uri))
- for (const artifactUri of getArtifactUris(store)) {
- void loadShapeDocument(artifactUri)
+ /** Fetches a profile and its SHACL artifacts, retaining successful results and reporting failures. */
+ async function loadProfile(uri: string): Promise<{
+ graphs: Record
+ error: string | null
+ }> {
+ const store = parseTurtle(await fetchRdfTurtle(uri))
+ const results = await Promise.allSettled(
+ getArtifactUris(store).map(async (artifactUri) => {
+ const graph = parseTurtle(await fetchRdfTurtle(artifactUri))
+ return [artifactUri, graph] as const
+ }),
+ )
+ const graphs: Record = {}
+ let error: string | null = null
+ for (const result of results) {
+ if (result.status === 'fulfilled') {
+ const [artifactUri, graph] = result.value
+ graphs[artifactUri] = graph
+ } else {
+ error ??=
+ result.reason instanceof Error ? result.reason.message : 'Unable to load resource shapes.'
}
- } catch (err) {
- console.warn(`Failed to load profile ${uri}`, err)
- }
- }
-
- /** Fetches and parses a SHACL shape document into shapeGraphs, used for property ordering and rendering hints. */
- async function loadShapeDocument(uri: string): Promise {
- if (shapeGraphs.value[uri]) return
-
- try {
- const store = parseTurtle(await fetchRdfTurtle(uri))
- shapeGraphs.value[uri] = store
- } catch (err) {
- console.warn(`Failed to load shape document ${uri}`, err)
}
+ return { graphs, error }
}
/** Fetches a child resource and stores a display summary (title, description, dates, theme) in childSummaries. */
@@ -136,7 +142,6 @@ export function useRdfLoader() {
rawTurtle,
childSummaries,
parentSummaries,
- shapeGraphs,
loadResource,
loadChildSummary,
loadParentChain,
diff --git a/src/composables/useResourceView.ts b/src/composables/useResourceView.ts
index 9f076d6..dff7b71 100644
--- a/src/composables/useResourceView.ts
+++ b/src/composables/useResourceView.ts
@@ -1,5 +1,6 @@
-import { computed, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
+import type { Store } from 'n3'
import {
getTitle,
getBreadcrumbs,
@@ -13,8 +14,10 @@ import {
getMetadataRows,
uriLabel,
} from './rdfUtils'
+import { getShapePropertyMap, getEditableFields } from './shaclUtils'
import { useRdfLoader, type ChildSummary } from './useRdfLoader'
import { getBaseUrl } from './urlUtils'
+import type { ResourceIdentifier } from './fdpApi'
export type { ChildSummary }
@@ -22,23 +25,31 @@ export type { ChildSummary }
* Derives all display data for ResourceView from the current route: resolves the resource URI,
* delegates fetching to useRdfLoader, and exposes computed title, breadcrumbs, metadata rows, and child sections.
*/
-export function useResourceView() {
+export function useResourceView({
+ loadChildSummaries = true,
+}: { loadChildSummaries?: boolean } = {}) {
const route = useRoute()
const fdpBaseUri = getBaseUrl()
const fdpUri = `${fdpBaseUri}/`
- const resourceUri = computed(() => {
+ const resource = computed((previous) => {
const resourceType = route.params.resourceType
const id = route.params.id
if (typeof resourceType === 'string' && typeof id === 'string') {
- return `${fdpBaseUri}/${resourceType}/${id}`
+ // Reuse the previous object supplied by Vue so unchanged route params do not trigger refetches.
+ if (previous?.resourceType === resourceType && previous.id === id) return previous
+ return { resourceType, id }
}
- return `${fdpBaseUri}/`
+ return null
})
+ const resourceUri = computed(() =>
+ resource.value ? `${fdpBaseUri}/${resource.value.resourceType}/${resource.value.id}` : fdpUri,
+ )
+
const {
loading,
error,
@@ -46,13 +57,16 @@ export function useResourceView() {
rawTurtle,
childSummaries,
parentSummaries,
- shapeGraphs,
loadResource,
loadChildSummary,
loadParentChain,
loadProfile,
} = useRdfLoader()
+ const shapeGraphs = ref>({})
+ const shapesLoading = ref(false)
+ const shapesError = ref(null)
+
function resourceLabel(uri: string): string {
return uriLabel(quads.value, uri)
}
@@ -79,13 +93,21 @@ export function useResourceView() {
const childSections = computed(() => getChildSections(quads.value, currentNodeUri.value))
+ // Share the computed shape map between metadata display and editable-field selection.
+ const shapeProperties = computed(() =>
+ getShapePropertyMap(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)),
+ )
+
const allMetadataRows = computed(() =>
- getMetadataRows(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)),
+ getMetadataRows(quads.value, currentNodeUri.value, shapeProperties.value),
)
const metadataRows = computed(() => allMetadataRows.value.rows)
const unknownMetadataRows = computed(() => allMetadataRows.value.unknownRows)
+ // The same properties as the metadata table, filtered by dash:editor instead of dash:viewer.
+ const editableFields = computed(() => getEditableFields(shapeProperties.value))
+
watch(
resourceUri,
async (uri) => {
@@ -94,17 +116,20 @@ export function useResourceView() {
{ immediate: true },
)
- watch(
- childSections,
- (sections) => {
- sections
- .flatMap((section) => section.items)
- .forEach((uri) => {
- void loadChildSummary(uri)
- })
- },
- { immediate: true },
- )
+ // The edit page needs breadcrumbs, but not child summaries.
+ if (loadChildSummaries) {
+ watch(
+ childSections,
+ (sections) => {
+ sections
+ .flatMap((section) => section.items)
+ .forEach((uri) => {
+ void loadChildSummary(uri)
+ })
+ },
+ { immediate: true },
+ )
+ }
watch(
currentNodeUri,
@@ -121,18 +146,42 @@ export function useResourceView() {
watch(
currentNodeUri,
- (uri) => {
+ async (uri, _previous, onCleanup) => {
+ let cancelled = false
+ onCleanup(() => {
+ cancelled = true
+ })
+ shapeGraphs.value = {}
+ shapesError.value = null
+ shapesLoading.value = false
if (!uri) return
const profileUri = getConformsTo(quads.value, uri)
- if (profileUri) void loadProfile(profileUri)
+ if (!profileUri) return
+ shapesLoading.value = true
+ try {
+ const result = await loadProfile(profileUri)
+ if (!cancelled) {
+ shapeGraphs.value = result.graphs
+ shapesError.value = result.error
+ }
+ } catch (err) {
+ if (!cancelled) {
+ shapesError.value = err instanceof Error ? err.message : 'Unable to load resource shapes.'
+ }
+ } finally {
+ if (!cancelled) shapesLoading.value = false
+ }
},
{ immediate: true },
)
return {
+ shapesLoading,
+ shapesError,
loading,
error,
rawTurtle,
+ resource,
resourceUri,
currentNodeUri,
title,
@@ -143,6 +192,7 @@ export function useResourceView() {
quads,
metadataRows,
unknownMetadataRows,
+ editableFields,
childSections,
childSummaries,
resourceLabel,
diff --git a/src/composables/useShapeForm.ts b/src/composables/useShapeForm.ts
new file mode 100644
index 0000000..c05fa1b
--- /dev/null
+++ b/src/composables/useShapeForm.ts
@@ -0,0 +1,23 @@
+import type { Store } from 'n3'
+import { computed, ref, watch, type Ref } from 'vue'
+import type { EditableField } from './shaclUtils'
+import { fieldsSignature, seedValues, type NodeValues } from './shapeForm'
+
+/** Re-seeds on record or structural changes, preserving edits through presentation changes. */
+export function useShapeForm(
+ store: Ref,
+ subjectUri: Ref,
+ fields: Ref,
+) {
+ const values = ref({})
+ // The store is read but not watched, so a store change alone does not re-seed. A full reload
+ // still resets: loadResource empties the store first, so the subject briefly resolves to null.
+ watch(
+ [subjectUri, computed(() => fieldsSignature(fields.value))],
+ ([uri]) => {
+ values.value = uri ? seedValues(store.value, uri, fields.value) : {}
+ },
+ { immediate: true },
+ )
+ return values
+}
diff --git a/src/composables/validationReport.ts b/src/composables/validationReport.ts
new file mode 100644
index 0000000..0389390
--- /dev/null
+++ b/src/composables/validationReport.ts
@@ -0,0 +1,85 @@
+import { DataFactory, type Term } from 'n3'
+import { compactUri, parseTurtle } from './rdfUtils'
+import {
+ RDF_TYPE,
+ SHACL_VALIDATION_REPORT,
+ SHACL_RESULT,
+ SHACL_DETAIL,
+ SHACL_FOCUS_NODE,
+ SHACL_RESULT_PATH,
+ SHACL_RESULT_MESSAGE,
+ SHACL_SOURCE_CONSTRAINT_COMPONENT,
+} from './vocabularies'
+
+export type ValidationResult = {
+ focusNode: Term | undefined
+ path: Term | undefined
+ messages: string[]
+}
+
+/** Reads SHACL results and nested details. Returns [] when no results can be parsed. */
+export function parseValidationReport(body: string): ValidationResult[] {
+ try {
+ const store = parseTurtle(body)
+ const { namedNode } = DataFactory
+ const objects = (subject: Term, predicate: string) =>
+ store.getObjects(subject, namedNode(predicate), null)
+ const results: ValidationResult[] = []
+ const visited = new Set()
+ function visit(result: Term) {
+ const key = `${result.termType}:${result.value}`
+ if (visited.has(key)) return
+ visited.add(key)
+ const messages = objects(result, SHACL_RESULT_MESSAGE)
+ .filter((term) => term.termType === 'Literal')
+ .map((term) => term.value)
+ const constraint = objects(result, SHACL_SOURCE_CONSTRAINT_COMPONENT)[0]
+ results.push({
+ focusNode: objects(result, SHACL_FOCUS_NODE)[0],
+ path: objects(result, SHACL_RESULT_PATH)[0],
+ messages: messages.length
+ ? messages
+ : [
+ constraint
+ ? `Constraint not satisfied: ${compactUri(constraint.value)}`
+ : 'Validation failed.',
+ ],
+ })
+ objects(result, SHACL_DETAIL).forEach(visit)
+ }
+ for (const report of store.getSubjects(
+ namedNode(RDF_TYPE),
+ namedNode(SHACL_VALIDATION_REPORT),
+ null,
+ )) {
+ objects(report, SHACL_RESULT).forEach(visit)
+ }
+ return results
+ } catch {
+ return []
+ }
+}
+
+/** Matches named subjects and simple property paths; report-local blank-node labels cannot identify form records. */
+export function validationResultMatchesField(
+ result: ValidationResult,
+ subjectUri: string | undefined,
+ path: string,
+): boolean {
+ return (
+ result.focusNode?.termType === 'NamedNode' &&
+ result.focusNode.value === subjectUri &&
+ result.path?.termType === 'NamedNode' &&
+ result.path.value === path
+ )
+}
+
+export function fieldValidationMessages(
+ results: ValidationResult[],
+ subjectUri: string | undefined,
+ path: string,
+): string[] {
+ return results
+ .filter((result) => validationResultMatchesField(result, subjectUri, path))
+ .flatMap((result) => result.messages)
+}
diff --git a/src/composables/vocabularies.ts b/src/composables/vocabularies.ts
index d576dbe..5f586d1 100644
--- a/src/composables/vocabularies.ts
+++ b/src/composables/vocabularies.ts
@@ -56,6 +56,7 @@ export const DCAT_CONTACT_POINT = `${DCAT}contactPoint`
export const DCAT_KEYWORD = `${DCAT}keyword`
export const DCAT_ACCESS_URL = `${DCAT}accessURL`
export const DCAT_DOWNLOAD_URL = `${DCAT}downloadURL`
+export const DCAT_FORMAT = `${DCAT}format`
export const DCAT_MEDIA_TYPE = `${DCAT}mediaType`
export const DCAT_BYTE_SIZE = `${DCAT}byteSize`
export const DCAT_PACKAGE_FORMAT = `${DCAT}packageFormat`
@@ -74,6 +75,8 @@ export const LDP_HAS_MEMBER_RELATION = `${LDP}hasMemberRelation`
// FOAF
export const FOAF_NAME = `${FOAF}name`
export const FOAF_HOMEPAGE = `${FOAF}homepage`
+// FDP shapes also use this nonstandard casing.
+export const FOAF_HOME_PAGE = `${FOAF}homePage`
// FDP
export const FDP_METADATA_ISSUED = `${FDP}metadataIssued`
@@ -99,7 +102,15 @@ export const PROF_HAS_ROLE = `${PROF}hasRole`
export const PROF_HAS_ARTIFACT = `${PROF}hasArtifact`
// SHACL
+export const SHACL_VALIDATION_REPORT = `${SH}ValidationReport`
+export const SHACL_RESULT = `${SH}result`
+export const SHACL_DETAIL = `${SH}detail`
+export const SHACL_FOCUS_NODE = `${SH}focusNode`
+export const SHACL_RESULT_PATH = `${SH}resultPath`
+export const SHACL_RESULT_MESSAGE = `${SH}resultMessage`
+export const SHACL_SOURCE_CONSTRAINT_COMPONENT = `${SH}sourceConstraintComponent`
export const SHACL_IRI = `${SH}IRI`
+export const SHACL_LITERAL = `${SH}Literal`
export const SHACL_NODE_SHAPE = `${SH}NodeShape`
export const SHACL_TARGET_CLASS = `${SH}targetClass`
export const SHACL_PROPERTY = `${SH}property`
@@ -131,6 +142,13 @@ export const SIO_IS_RELATED_TO = `${SIO}SIO_000332`
export const DASH_VIEWER = `${DASH}viewer`
export const DASH_LABEL_VIEWER = `${DASH}LabelViewer`
export const DASH_URI_VIEWER = `${DASH}URIViewer`
+export const DASH_EDITOR = `${DASH}editor`
+export const DASH_TEXT_FIELD_EDITOR = `${DASH}TextFieldEditor`
+export const DASH_TEXT_AREA_EDITOR = `${DASH}TextAreaEditor`
+export const DASH_URI_EDITOR = `${DASH}URIEditor`
+export const DASH_BLANK_NODE_EDITOR = `${DASH}BlankNodeEditor`
+export const DASH_DATE_PICKER_EDITOR = `${DASH}DatePickerEditor`
+export const DASH_DATE_TIME_PICKER_EDITOR = `${DASH}DateTimePickerEditor`
export const prefixes: Record = {
[DCT]: 'dct',
diff --git a/src/router/guard.ts b/src/router/guard.ts
index bdb6c32..0148732 100644
--- a/src/router/guard.ts
+++ b/src/router/guard.ts
@@ -1,16 +1,26 @@
import type { RouteLocationNormalized } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { apiDocsReady, isOperationOffered } from '@/composables/apiDocs'
+import { canEditResource } from '@/composables/useMeta'
+import type { ResourceIdentifier } from '@/composables/fdpApi'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
requiresAdmin?: boolean
+ // Whether the signed-in user can write this exact resource, checked live (see useMeta.canEdit).
+ requiresEdit?: boolean
// operationId this route needs the backend's api-docs to advertise, if any.
requiresOperation?: string
}
}
+/** Mirrors useResourceView's own resource identifier, derived the same way from route params. */
+function routeResource(to: RouteLocationNormalized): ResourceIdentifier {
+ const { resourceType, id } = to.params
+ return typeof resourceType === 'string' && typeof id === 'string' ? { resourceType, id } : null
+}
+
/**
* Extracted from router/index.ts so route access rules can be unit-tested without
* constructing a browser-history router.
@@ -19,6 +29,13 @@ export async function checkRouteAccess(to: RouteLocationNormalized) {
const { isLoggedIn, isAdmin } = useAuth()
if (to.meta.requiresAuth && !isLoggedIn.value) return '/login'
if (to.meta.requiresAdmin && !isAdmin.value) return '/not-allowed'
+ if (to.meta.requiresEdit) {
+ try {
+ if (!(await canEditResource(routeResource(to)))) return '/not-allowed'
+ } catch {
+ // The page loads access independently and displays any request failure.
+ }
+ }
// Plain resource routes have no requiresOperation, so they never await apiDocsReady.
if (!to.meta.requiresOperation) return
await apiDocsReady
diff --git a/src/router/index.ts b/src/router/index.ts
index f65af01..c770241 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import ResourceView from '@/views/ResourceView.vue'
+import ResourceEditView from '@/views/ResourceEditView.vue'
import LoginView from '@/views/LoginView.vue'
import SearchView from '@/views/SearchView.vue'
import NotAllowedView from '@/views/NotAllowedView.vue'
@@ -20,6 +21,18 @@ const router = createRouter({
name: 'resource',
component: ResourceView,
},
+ {
+ path: '/edit',
+ name: 'fdp-root-edit',
+ component: ResourceEditView,
+ meta: { requiresAuth: true, requiresEdit: true },
+ },
+ {
+ path: '/:resourceType/:id/edit',
+ name: 'resource-edit',
+ component: ResourceEditView,
+ meta: { requiresAuth: true, requiresEdit: true },
+ },
{
path: '/login',
name: 'login',
diff --git a/src/views/ResourceEditView.vue b/src/views/ResourceEditView.vue
new file mode 100644
index 0000000..7fa3eec
--- /dev/null
+++ b/src/views/ResourceEditView.vue
@@ -0,0 +1,313 @@
+
+
+
+
+
+
+
+
+ Loading…
+
+ Error: {{ error || shapesError || accessError }}
+
+ You cannot edit this resource.
+
+
+
+
{{ saveError }}
+
+ -
+ {{ predicateLabel(result.path.value) }}:
+
+ {{ result.messages.join(' ') }}
+
+
+
+ View report
+
+
+
+
+
+ No editable fields are declared for this resource.
+
+
+ Cancel
+
+
+
+
+
diff --git a/src/views/ResourceView.vue b/src/views/ResourceView.vue
index e711b9e..08c9f83 100644
--- a/src/views/ResourceView.vue
+++ b/src/views/ResourceView.vue
@@ -1,15 +1,20 @@
@@ -78,7 +84,28 @@ const {