enrich entries

This commit is contained in:
kempersc 2025-12-01 00:37:24 +01:00
parent f3c149b1bb
commit 2497e5913f
73 changed files with 3297 additions and 873 deletions

View file

@ -190,3 +190,15 @@ contact_info:
email: pietvangorp@casema.nl
location: Oosteind (gemeente Oosterhout), Noord-Brabant
source: https://www.brabantserfgoed.nl/page/4508/heemkundegroep-ulendonc
locations:
- city: Oosteind
street_address: Maalderijstraat 22
postal_code: '4909 AR'
municipality: Oosterhout
region: Noord-Brabant
country: NL
latitude: 51.6430903
longitude: 4.8979482
location_source: user_provided_google_maps_link
location_timestamp: '2025-12-01T06:20:00+00:00'
notes: Address location provided via Google Maps short link

View file

@ -193,3 +193,33 @@ social_media:
facebook_page: https://www.facebook.com/historischemuurreclameszwolle/
enrichment_timestamp: '2025-11-30T18:39:58.119162+00:00'
enrichment_method: user_provided
google_maps_enrichment:
search_status: found_via_user_link
user_provided_link: https://maps.app.goo.gl/Ds4Uzih4bhTcAEcQ6
place_name: Ervenconsulent
place_type: Consultant
address: Aan de Stadsmuur 79-83, 8011 VD Zwolle
plus_code: G37W+P9 Zwolle
coordinates:
latitude: 52.5143008
longitude: 6.0959301
google_maps_url: https://www.google.com/maps/place/Ervenconsulent/@52.5143008,6.0959301,17z
phone: 038 421 3257
website: http://www.hetoversticht.nl/erfadvies
enrichment_timestamp: '2025-12-01T06:30:00+00:00'
notes: >
User confirmed that Ervenconsulent at Aan de Stadsmuur 79-83 is also the center
for Historische Muurreclames Zwolle. The organization operates from this location,
which is part of Het Oversticht heritage advisory organization.
locations:
- city: Zwolle
street_address: Aan de Stadsmuur 79-83
postal_code: '8011 VD'
region: Overijssel
country: NL
latitude: 52.5143008
longitude: 6.0959301
notes: >
Location confirmed by user. Historische Muurreclames Zwolle operates from the
Ervenconsulent / Het Oversticht building at Aan de Stadsmuur 79-83.
location_timestamp: '2025-12-01T06:30:00+00:00'

View file

@ -48,12 +48,41 @@ notes: |-
google_maps_enrichment:
search_attempted: true
search_query: "Heemkring Glatbeke Opglabbeek Belgium"
result: not_found
result: found_via_user_link
user_provided_link: https://maps.app.goo.gl/7vvyQr8ByZ7d8PnG9
place_name: Troempeelke - UiTbalie
place_type: Cultural center / UiTbalie
address: Gildenstraat 10, 3660 Oudsbergen, Belgium
plus_code: 2HVM+JG Oudsbergen, Belgium
coordinates:
latitude: 51.0440122
longitude: 5.5838605
google_maps_url: https://www.google.com/maps/place/Troempeelke+-+UiTbalie/@51.0440122,5.5838605,17z
rating: 4.2
total_reviews: 6
phone: +32 89 81 09 10
business_status: Permanently closed
business_status_note: >
Google Maps shows "Permanently closed" for Troempeelke - UiTbalie. This was the
cultural center / UiTbalie (tourism office) in Opglabbeek where Heemkring Glatbeke
may have been located. The heemkring organization may still operate without a
physical public location.
notes: |
No Google Maps listing found for Heemkring Glatbeke.
Related heemkringen in the area:
- Geschied- en Heemkundige Kring Groot-Bree Vzw (Local history museum)
- Heemkring Heidebloemke Vzw (Historical society in Genk)
The heemkring may not have a physical location or Google Maps presence.
User provided Google Maps link pointing to Troempeelke - UiTbalie, a cultural
center in Opglabbeek/Oudsbergen. This appears to be the former location where
Heemkring Glatbeke was based or held meetings.
enrichment_timestamp: '2025-11-30T21:55:00+00:00'
source: Google Maps search
updated_timestamp: '2025-12-01T06:20:00+00:00'
source: Google Maps via user-provided link
locations:
- city: Opglabbeek
street_address: Gildenstraat 10
postal_code: '3660'
municipality: Oudsbergen
region: Limburg
country: BE
latitude: 51.0440122
longitude: 5.5838605
location_note: >
Location via Troempeelke - UiTbalie cultural center (now permanently closed).
Opglabbeek merged into Oudsbergen municipality in 2019.

View file

@ -48,10 +48,29 @@ notes: |-
google_maps_enrichment:
search_attempted: true
search_query: "Historische Werkgroep Kynhout De Knipe Friesland"
result: not_found
result: found_via_user_link
user_provided_link: https://maps.app.goo.gl/ZBGEvEY94QPuMmyk9
place_name: Dominee Veenweg 30
place_type: Building (residential address)
address: Dominee Veenweg 30, 8456 HS De Knipe
coordinates:
latitude: 52.9657872
longitude: 5.9947032
google_maps_url: https://www.google.com/maps/place/Dominee+Veenweg+30/@52.9657872,5.9947032,17z
notes: |
No Google Maps listing found for Historische Werkgroep Kynhout.
The organization likely doesn't have a physical location or Google Maps presence.
De Knipe is correctly identified as a village in Friesland.
User provided Google Maps link pointing to a residential address in De Knipe.
This is likely the contact address or meeting location for the historical
working group, rather than a public museum or cultural center.
enrichment_timestamp: '2025-11-30T22:05:00+00:00'
source: Google Maps search
updated_timestamp: '2025-12-01T06:20:00+00:00'
source: Google Maps via user-provided link
locations:
- city: De Knipe
street_address: Dominee Veenweg 30
postal_code: '8456 HS'
municipality: Heerenveen
region: Friesland
country: NL
latitude: 52.9657872
longitude: 5.9947032
location_note: Contact/meeting address for the historical working group

View file

@ -52,6 +52,12 @@ google_maps_enrichment:
rating: null
total_ratings: 0
address: Clarissenhoeve 52, 5258 PK Berlicum
alternate_address: Koesteeg 37, 5258 TN Berlicum
coordinates:
latitude: 51.6851289
longitude: 5.4367943
google_maps_url: https://www.google.com/maps/place/Koesteeg+37/@51.6851289,5.4367943,17z
user_provided_link: https://maps.app.goo.gl/7vp195ZWR9jaLBDE9
phone: "073 503 8368"
website: https://www.deplaets.nl/
plus_code: M9HW+R2 Berlicum
@ -59,4 +65,19 @@ google_maps_enrichment:
hours_status: Closed · Opens 9 am Tue
wheelchair_accessible: true
enrichment_timestamp: '2025-11-30T22:35:00+00:00'
source: Google Maps search
updated_timestamp: '2025-12-01T06:20:00+00:00'
source: Google Maps search + user-provided link
notes: >
User-provided link points to Koesteeg 37, Berlicum. The official Google Maps
listing shows Clarissenhoeve 52. Both addresses are in Berlicum - Koesteeg 37
may be a secondary location or meeting place.
locations:
- city: Berlicum
street_address: Koesteeg 37
postal_code: '5258 TN'
municipality: Sint-Michielsgestel
region: Noord-Brabant
country: NL
latitude: 51.6851289
longitude: 5.4367943
location_note: User-provided location (Koesteeg 37); official listing at Clarissenhoeve 52

View file

@ -60,10 +60,41 @@ google_maps_enrichment:
rating: 4.5
total_ratings: 10
address: Appelhofdwarsstraat 2, 7641 BX Wierden
coordinates:
latitude: 52.3580015
longitude: 6.5932438
google_maps_url: https://www.google.com/maps/place/Historische+Kring+Wierden/@52.3580015,6.5932438,17z
user_provided_link: https://maps.app.goo.gl/zmxXujGdjutEfxcAA
phone: "0546 572 651"
website: https://historischekringwierden.nl/
plus_code: 9H5V+67 Wierden
business_status: OPERATIONAL
hours_status: Closed · Opens 2 pm Wed
review_summary:
5_stars: 6
4_stars: 3
3_stars: 1
2_stars: 0
1_stars: 0
sample_reviews:
- reviewer: Henk Hollegien
rating: 5
date: 7 years ago
text: Enthusiastic volunteers and wonderful exhibitions on a wide variety of subjects, at least 3 per year.
- reviewer: Sander Veldkamp
rating: 5
date: 2 years ago
text: Interesting exhibition about the Twente Canal and the adjacent associations.
enrichment_timestamp: '2025-11-30T22:25:00+00:00'
source: Google Maps search
updated_timestamp: '2025-12-01T06:20:00+00:00'
source: Google Maps search + user-provided link
locations:
- city: Wierden
street_address: Appelhofdwarsstraat 2
postal_code: '7641 BX'
municipality: Wierden
region: Overijssel
country: NL
latitude: 52.3580015
longitude: 6.5932438
location_note: Located in "gebouw Van Buuren Stee" building

@ -1 +1 @@
Subproject commit 07aedc21cc3d3e626c702fae7631f4f3bfe3a1ac
Subproject commit 4aeb0543f9becb95a320dd60c547b86f7134cbe3

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"generated": "2025-11-30T22:26:27.880Z",
"generated": "2025-11-30T23:33:22.231Z",
"version": "1.0.0",
"categories": [
{
@ -669,6 +669,11 @@
"path": "modules/slots/alternative_observed_names.yaml",
"category": "slots"
},
{
"name": "altitude",
"path": "modules/slots/altitude.yaml",
"category": "slots"
},
{
"name": "api_endpoint",
"path": "modules/slots/api_endpoint.yaml",
@ -879,6 +884,11 @@
"path": "modules/slots/documentation_source.yaml",
"category": "slots"
},
{
"name": "documentation_url",
"path": "modules/slots/documentation_url.yaml",
"category": "slots"
},
{
"name": "emic_name",
"path": "modules/slots/emic_name.yaml",
@ -1164,6 +1174,11 @@
"path": "modules/slots/longitude.yaml",
"category": "slots"
},
{
"name": "managed_by",
"path": "modules/slots/managed_by.yaml",
"category": "slots"
},
{
"name": "managed_collections",
"path": "modules/slots/managed_collections.yaml",
@ -1464,6 +1479,11 @@
"path": "modules/slots/started_at_time.yaml",
"category": "slots"
},
{
"name": "storage_location",
"path": "modules/slots/storage_location.yaml",
"category": "slots"
},
{
"name": "street_address",
"path": "modules/slots/street_address.yaml",

View file

@ -9,7 +9,7 @@ imports:
- linkml:types
- ../metadata
- ../enums/AppellationTypeEnum
- CustodianName
- ./CustodianName
classes:

View file

@ -4,7 +4,7 @@ title: Archive Organization Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/access_policy
classes:

View file

@ -3,7 +3,7 @@ name: BioCustodianType
title: Biological and Zoological Custodian Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/collection_size
classes:

View file

@ -34,7 +34,7 @@ imports:
- linkml:types
- ../enums/CallForApplicationStatusEnum
- ../enums/FundingRequirementTypeEnum
- FundingRequirement
- ./FundingRequirement
- ../slots/contact_email
- ../slots/keywords

View file

@ -14,6 +14,7 @@ imports:
- ./CustodianObservation
- ./ReconstructionActivity
- ./TimeSpan
- ../slots/documentation_url
prefixes:
linkml: https://w3id.org/linkml/
@ -701,9 +702,7 @@ slots:
description: Vendor website URL
range: uri
documentation_url:
description: Documentation URL
range: uri
# NOTE: documentation_url imported from global slot ../slots/documentation_url.yaml
repository_url:
description: Source code repository URL

View file

@ -18,6 +18,7 @@ imports:
- ./Storage
- ../enums/ArchiveProcessingStatusEnum
- ../slots/access_restrictions
- ../slots/storage_location
prefixes:
linkml: https://w3id.org/linkml/
@ -680,9 +681,8 @@ slots:
description: Estimated physical/digital extent
range: string
storage_location:
description: Physical storage location(s)
range: Storage
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
tracked_in_cms:
description: CMS tracking this accession

View file

@ -24,6 +24,7 @@ imports:
- ../slots/oai_pmh_endpoint
- ../slots/platform_type
- ../slots/platform_name
- ../slots/storage_location
prefixes:
linkml: https://w3id.org/linkml/
@ -776,10 +777,8 @@ slots:
# NOTE: preservation_level imported from global slot ../slots/preservation_level.yaml
storage_location:
slot_uri: premis:storedAt
description: Primary storage location for digital content
range: string
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
fixity_check_date:
slot_uri: premis:fixity

View file

@ -37,7 +37,7 @@ see_also:
- https://www.wikidata.org/wiki/Q132560468 # university archive
imports:
- CustodianType
- ./CustodianType
prefixes:
hc: https://nde.nl/ontology/hc/

View file

@ -4,7 +4,7 @@ title: Gallery Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
GalleryType:

View file

@ -25,6 +25,10 @@ prefixes:
imports:
- linkml:types
- ../metadata
- ../slots/geonames_id
- ../slots/latitude
- ../slots/longitude
- ../slots/altitude
types:
WktLiteral:
@ -43,39 +47,9 @@ slots:
range: uriorcurie
description: "Unique identifier for this geospatial place"
latitude:
range: float
slot_uri: wgs84:lat
description: >-
WGS84 latitude coordinate (decimal degrees).
Positive = North, Negative = South.
minimum_value: -90.0
maximum_value: 90.0
examples:
- value: 52.3600
description: "Amsterdam latitude"
longitude:
range: float
slot_uri: wgs84:long
description: >-
WGS84 longitude coordinate (decimal degrees).
Positive = East, Negative = West.
minimum_value: -180.0
maximum_value: 180.0
examples:
- value: 4.8852
description: "Amsterdam longitude"
altitude:
range: float
slot_uri: wgs84:alt
description: >-
Altitude above sea level (meters).
Optional - use for elevated or underground locations.
examples:
- value: -2.0
description: "Amsterdam (below sea level)"
# NOTE: latitude imported from global slot ../slots/latitude.yaml
# NOTE: longitude imported from global slot ../slots/longitude.yaml
# NOTE: altitude imported from global slot ../slots/altitude.yaml
geometry_wkt:
range: string
@ -119,22 +93,7 @@ slots:
- value: "EPSG:28992"
description: "Dutch Rijksdriehoeksstelsel"
geonames_id:
range: integer
slot_uri: geonames:geonameId
description: >-
GeoNames numeric identifier.
Resolves to https://www.geonames.org/{id}/
Use for:
- Linking to GeoNames knowledge base
- Disambiguating place names (41 "Springfield"s in USA)
- Accessing hierarchical administrative data
examples:
- value: 2759794
description: "Amsterdam (GeoNames ID)"
- value: 6930126
description: "Rijksmuseum building"
# NOTE: geonames_id imported from global slot ../slots/geonames_id.yaml
osm_id:
range: string

View file

@ -16,6 +16,8 @@ imports:
- ./TimeSpan
- ../enums/GiftShopTypeEnum
- ../enums/ProductCategoryEnum
- ../slots/staff_count
- ../slots/managed_by
prefixes:
linkml: https://w3id.org/linkml/
@ -747,17 +749,13 @@ slots:
description: Visitor to purchase conversion rate
range: float
staff_count:
description: Number of shop staff
range: integer
# NOTE: staff_count imported from global slot ../slots/staff_count.yaml
square_meters:
description: Retail floor space
range: float
managed_by:
description: Management structure
range: string
# NOTE: managed_by imported from global slot ../slots/managed_by.yaml
supplier_relationships:
description: Key supplier relationships

View file

@ -39,7 +39,7 @@ see_also:
- https://www.wikidata.org/wiki/Q15755503 # archaeological society
imports:
- CustodianType
- ./CustodianType
prefixes:
hc: https://nde.nl/ontology/hc/

View file

@ -4,7 +4,7 @@ title: Library Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/cataloging_standard
classes:

View file

@ -4,7 +4,7 @@ title: Museum Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/collection_focus
- ../slots/cataloging_standard

View file

@ -4,7 +4,7 @@ title: Official Institution Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
OfficialInstitutionType:

View file

@ -34,6 +34,7 @@ imports:
- ../slots/funding_source
- ../slots/contact_email
- ../slots/keywords
- ../slots/documentation_url
default_prefix: hc
@ -86,9 +87,7 @@ slots:
range: uriorcurie
multivalued: true
description: Related or predecessor/successor projects
documentation_url:
range: uri
description: URL to project documentation
# NOTE: documentation_url imported from global slot ../slots/documentation_url.yaml
# NOTE: contact_email imported from global slot ../slots/contact_email.yaml

View file

@ -9,10 +9,10 @@ imports:
- linkml:types
- ../metadata
- ../enums/ReconstructionActivityTypeEnum
- ReconstructionAgent
- TimeSpan
- CustodianObservation
- ConfidenceMeasure
- ./ReconstructionAgent
- ./TimeSpan
- ./CustodianObservation
- ./ConfidenceMeasure
classes:

View file

@ -34,6 +34,7 @@ imports:
- ../metadata
- ./TimeSpan
- ./Jurisdiction
- ./RegistrationAuthority
- ../slots/jurisdiction
- ../slots/description
- ../slots/website

View file

@ -4,7 +4,7 @@ title: Research Organization Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
ResearchOrganizationType:

View file

@ -14,8 +14,8 @@ title: Settlement Class
imports:
- linkml:types
- Country
- Subregion
- ./Country
- ./Subregion
- ../slots/country
- ../slots/subregion
- ../slots/geonames_id

View file

@ -37,6 +37,8 @@ imports:
- ./StorageConditionPolicy
- ../enums/StorageTypeEnum
- ../enums/StorageStandardEnum
- ../slots/storage_location
- ../slots/managed_by
classes:
Storage:
@ -451,9 +453,8 @@ slots:
description: Description of storage facility
range: string
storage_location:
description: Physical location (AuxiliaryPlace)
range: AuxiliaryPlace
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
capacity_description:
description: Qualitative capacity description
@ -494,6 +495,4 @@ slots:
range: StorageCondition
multivalued: true
managed_by:
description: Managing organizational unit
range: string
# NOTE: managed_by imported from global slot ../slots/managed_by.yaml

View file

@ -15,7 +15,7 @@ title: Subregion Class
imports:
- linkml:types
- Country
- ./Country
- ../slots/country
classes:

View file

@ -0,0 +1,24 @@
# Global Slot: altitude
# Altitude above sea level in meters
id: https://nde.nl/ontology/hc/slot/altitude
name: altitude-slot
title: Altitude Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
wgs84: http://www.w3.org/2003/01/geo/wgs84_pos#
imports:
- linkml:types
slots:
altitude:
slot_uri: wgs84:alt
range: float
description: >-
Altitude above sea level (meters).
Optional - use for elevated or underground locations.
exact_mappings:
- wgs84:alt

View file

@ -0,0 +1,22 @@
# Global Slot: documentation_url
# URL to documentation for a project, system, or resource
id: https://nde.nl/ontology/hc/slot/documentation_url
name: documentation-url-slot
title: Documentation URL Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
schema: http://schema.org/
imports:
- linkml:types
slots:
documentation_url:
slot_uri: schema:documentation
description: URL to documentation for this entity
range: uri
exact_mappings:
- schema:documentation

View file

@ -0,0 +1,22 @@
# Global Slot: managed_by
# Identifies the entity or organizational unit managing something
id: https://nde.nl/ontology/hc/slot/managed_by
name: managed-by-slot
title: Managed By Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
org: http://www.w3.org/ns/org#
imports:
- linkml:types
slots:
managed_by:
slot_uri: org:linkedTo
description: Entity or organizational unit managing this resource
range: string
exact_mappings:
- org:linkedTo

View file

@ -0,0 +1,24 @@
# Global Slot: storage_location
# Physical or logical location where materials are stored
id: https://nde.nl/ontology/hc/slot/storage_location
name: storage-location-slot
title: Storage Location Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
premis: http://www.loc.gov/standards/premis/rdf/v3/
imports:
- linkml:types
slots:
storage_location:
slot_uri: premis:storedAt
description: >-
Physical or logical location where materials are stored.
Range varies by context - can be AuxiliaryPlace, Storage, or string.
range: uriorcurie
exact_mappings:
- premis:storedAt

View file

@ -10,9 +10,9 @@ import {
Navigate,
} from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Layout } from './components/layout/Layout';
import { HomeLayout } from './components/layout/HomeLayout';
import { Visualize } from './pages/Visualize';
import { Database } from './pages/Database';
import { Settings } from './pages/Settings';
@ -26,27 +26,12 @@ import ProjectPlanPage from './pages/ProjectPlanPage';
import './App.css';
// Create router configuration with protected routes
// All pages use the standard Layout with navigation at the top
const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
// Home page with navigation at bottom
{
path: '/',
element: (
<ProtectedRoute>
<HomeLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <ProjectPlanPage />,
},
],
},
// Other pages with navigation at top
{
path: '/',
element: (
@ -55,6 +40,11 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
children: [
{
// Home page shows the Project Plan
index: true,
element: <ProjectPlanPage />,
},
{
path: 'visualize',
element: <Visualize />,
@ -98,9 +88,11 @@ const router = createBrowserRouter([
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
<LanguageProvider>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</LanguageProvider>
);
}

View file

@ -1,67 +0,0 @@
/**
* Home Layout Styles
*
* Navigation appears at the very bottom of the page.
*/
.home-layout {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.home-layout-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
/* Bottom section containing navigation and footer */
.home-layout-bottom {
margin-top: auto;
display: flex;
flex-direction: column;
}
/* Override navigation styles for bottom placement */
.home-layout .navigation {
position: relative;
order: 1;
}
/* Footer at the very bottom */
.home-layout-footer {
background: #172a59;
color: rgba(255, 255, 255, 0.9);
padding: 0.75rem 1.5rem;
font-size: 0.85rem;
border-top: 3px solid #0a3dfa;
order: 2;
}
.home-layout-footer .footer-content {
display: flex;
justify-content: center;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.home-layout-footer .footer-copyright {
color: rgba(255, 255, 255, 0.8);
}
.home-layout-footer .footer-copyright a {
color: #fff;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.home-layout-footer .footer-copyright a:hover {
color: #fa5200;
text-decoration: underline;
}

View file

@ -1,44 +0,0 @@
/**
* Home Layout Component - Navigation at bottom
*
* This layout places the navigation bar at the very bottom of the page,
* so users only see it when scrolling all the way down.
*
* © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved.
*/
import { Outlet } from 'react-router-dom';
import { Navigation } from './Navigation';
import './HomeLayout.css';
export function HomeLayout() {
const currentYear = new Date().getFullYear();
return (
<div className="home-layout">
<div className="home-layout-content">
<Outlet />
{/* Navigation appears at the bottom after all content */}
<div className="home-layout-bottom">
<Navigation />
<footer className="home-layout-footer">
<div className="footer-content">
<div className="footer-copyright">
© {currentYear}{' '}
<a href="https://netwerkdigitaalerfgoed.nl" target="_blank" rel="noopener noreferrer">
Netwerk Digitaal Erfgoed
</a>
{' & '}
<a href="https://textpast.com" target="_blank" rel="noopener noreferrer">
TextPast
</a>
. All rights reserved.
</div>
</div>
</footer>
</div>
</div>
</div>
);
}

View file

@ -25,74 +25,17 @@
flex: 1;
}
/* Footer Styles - now inside scrollable area */
/* Footer Styles - minimal, at the very bottom */
.layout-footer {
background: #172a59; /* NDE dark blue */
color: rgba(255, 255, 255, 0.9);
padding: 0.75rem 1.5rem;
font-size: 0.85rem;
border-top: 3px solid #0a3dfa; /* NDE primary blue accent */
background: transparent;
color: rgba(23, 42, 89, 0.5); /* Subtle NDE dark blue */
padding: 1.5rem;
font-size: 0.8rem;
text-align: center;
margin-top: auto; /* Push to bottom of flex container */
flex-shrink: 0; /* Don't shrink */
}
/* Minimal footer for full-screen pages */
.layout-footer.footer-minimal {
display: none; /* Hide completely on fullscreen pages */
}
.layout-footer.footer-minimal .footer-content {
justify-content: center;
}
.layout-footer.footer-minimal .footer-copyright {
color: rgba(100, 100, 100, 0.7);
font-size: 0.75rem;
pointer-events: auto;
}
.layout-footer.footer-minimal .footer-copyright a {
color: rgba(100, 100, 100, 0.8);
}
.layout-footer.footer-minimal .footer-copyright a:hover {
color: #0a3dfa;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
flex-wrap: wrap;
gap: 0.75rem;
}
.footer-copyright {
color: rgba(255, 255, 255, 0.8);
}
.footer-copyright a {
color: #fff;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.footer-copyright a:hover {
color: #fa5200; /* NDE orange on hover */
text-decoration: underline;
}
/* Responsive footer */
@media (max-width: 600px) {
.footer-content {
flex-direction: column;
text-align: center;
}
}
/* Auth Loading State */
.auth-loading {
display: flex;

View file

@ -8,7 +8,7 @@ import { Outlet, useLocation } from 'react-router-dom';
import { Navigation } from './Navigation';
import './Layout.css';
// Pages that should hide the footer completely (it appears at the bottom when scrolling)
// Pages that should hide the footer completely
const FULLSCREEN_PAGES = ['/visualize', '/map', '/database', '/query-builder', '/linkml', '/ontology', '/stats'];
export function Layout() {
@ -23,20 +23,12 @@ export function Layout() {
<Navigation />
<div className="layout-content">
<Outlet />
{/* Footer only shows on non-fullscreen pages, or at very bottom of scroll */}
{/* Minimal footer - only shows on pages with scrollable content */}
{!isFullscreenPage && (
<footer className="layout-footer">
<div className="footer-content">
<div className="footer-copyright">
© {currentYear}{' '}
<a href="https://netwerkdigitaalerfgoed.nl" target="_blank" rel="noopener noreferrer">
Netwerk Digitaal Erfgoed
</a>
{' & '}
<a href="https://textpast.com" target="_blank" rel="noopener noreferrer">
TextPast
</a>
. All rights reserved.
© {currentYear} Netwerk Digitaal Erfgoed & TextPast. All rights reserved.
</div>
</div>
</footer>

View file

@ -75,10 +75,48 @@
gap: 2rem; /* Larger gap between links like NDE */
}
/* Language Toggle */
.nav-lang-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px solid rgba(23, 42, 89, 0.2);
border-radius: 4px;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-family: 'Roboto', Helvetica, Arial, sans-serif;
font-size: 13px;
color: #172a59;
transition: all 0.2s ease;
margin-left: auto;
margin-right: 0.5rem;
}
.nav-lang-toggle:hover {
border-color: #0a3dfa;
background: rgba(10, 61, 250, 0.05);
}
.lang-active {
color: #0a3dfa;
font-weight: 600;
}
.lang-inactive {
color: #9ca3af;
font-weight: 400;
}
.lang-separator {
color: rgba(23, 42, 89, 0.3);
font-weight: 300;
}
/* User Account Dropdown */
.nav-user {
position: relative;
margin-left: 1.5rem;
margin-left: 0.5rem;
}
.nav-user-btn {

View file

@ -1,16 +1,19 @@
/**
* Navigation Component
* Styled following Netwerk Digitaal Erfgoed (NDE) house style
* With bilingual support (NL/EN)
*/
import { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage, translations } from '../../contexts/LanguageContext';
import './Navigation.css';
export function Navigation() {
const location = useLocation();
const { user, logout } = useAuth();
const { language, toggleLanguage } = useLanguage();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
@ -29,6 +32,11 @@ export function Navigation() {
return location.pathname === path;
};
// Get translated nav text
const t = (key: keyof typeof translations.nav) => {
return language === 'en' ? translations.nav[key].en : translations.nav[key].nl;
};
return (
<nav className="navigation">
<div className="nav-container">
@ -47,58 +55,70 @@ export function Navigation() {
to="/"
className={`nav-link ${isActive('/') ? 'active' : ''}`}
>
Home
{t('home')}
</Link>
<Link
to="/visualize"
className={`nav-link ${isActive('/visualize') ? 'active' : ''}`}
>
Visualize
{t('visualize')}
</Link>
<Link
to="/database"
className={`nav-link ${isActive('/database') ? 'active' : ''}`}
>
Database
{t('database')}
</Link>
<Link
to="/query-builder"
className={`nav-link ${isActive('/query-builder') ? 'active' : ''}`}
>
Query Builder
{t('queryBuilder')}
</Link>
<Link
to="/linkml"
className={`nav-link ${isActive('/linkml') ? 'active' : ''}`}
>
LinkML
{t('linkml')}
</Link>
<Link
to="/ontology"
className={`nav-link ${isActive('/ontology') ? 'active' : ''}`}
>
Ontology
{t('ontology')}
</Link>
<Link
to="/map"
className={`nav-link ${isActive('/map') ? 'active' : ''}`}
>
Map
{t('map')}
</Link>
<Link
to="/stats"
className={`nav-link ${isActive('/stats') ? 'active' : ''}`}
>
Stats
{t('stats')}
</Link>
<Link
to="/settings"
className={`nav-link ${isActive('/settings') ? 'active' : ''}`}
>
Settings
{t('settings')}
</Link>
</div>
{/* Language Toggle */}
<button
className="nav-lang-toggle"
onClick={toggleLanguage}
aria-label={language === 'nl' ? 'Switch to English' : 'Schakel naar Nederlands'}
title={language === 'nl' ? 'Switch to English' : 'Schakel naar Nederlands'}
>
<span className={language === 'nl' ? 'lang-active' : 'lang-inactive'}>NL</span>
<span className="lang-separator">|</span>
<span className={language === 'en' ? 'lang-active' : 'lang-inactive'}>EN</span>
</button>
{/* User Account Dropdown */}
{user && (
<div className="nav-user" ref={userMenuRef}>
@ -118,7 +138,7 @@ export function Navigation() {
<span className="nav-user-role">{user.role}</span>
</div>
<button onClick={logout} className="nav-user-logout">
Sign Out
{t('signOut')}
</button>
</div>
)}

View file

@ -0,0 +1,174 @@
/**
* Language Context for bilingual support (NL/EN)
*
* Provides a global language state that can be used across all pages.
* LinkML and ontology descriptions should remain in their original language.
*
* © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved.
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
export type Language = 'nl' | 'en';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
toggleLanguage: () => void;
t: (nl: string, en?: string) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
interface LanguageProviderProps {
children: ReactNode;
}
export function LanguageProvider({ children }: LanguageProviderProps) {
// Default to Dutch as primary language
const [language, setLanguage] = useState<Language>(() => {
// Check localStorage for saved preference
const saved = localStorage.getItem('glam-language');
return (saved === 'en' || saved === 'nl') ? saved : 'nl';
});
const handleSetLanguage = useCallback((lang: Language) => {
setLanguage(lang);
localStorage.setItem('glam-language', lang);
}, []);
const toggleLanguage = useCallback(() => {
const newLang = language === 'nl' ? 'en' : 'nl';
handleSetLanguage(newLang);
}, [language, handleSetLanguage]);
// Translation helper - returns English text if available and language is EN, otherwise Dutch
const t = useCallback((nl: string, en?: string): string => {
return language === 'en' && en ? en : nl;
}, [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage, toggleLanguage, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}
// Common translations for UI elements
export const translations = {
// Navigation
nav: {
home: { nl: 'Home', en: 'Home' },
visualize: { nl: 'Visualiseren', en: 'Visualize' },
database: { nl: 'Database', en: 'Database' },
queryBuilder: { nl: 'Query Builder', en: 'Query Builder' },
linkml: { nl: 'LinkML', en: 'LinkML' },
ontology: { nl: 'Ontologie', en: 'Ontology' },
map: { nl: 'Kaart', en: 'Map' },
stats: { nl: 'Statistieken', en: 'Stats' },
settings: { nl: 'Instellingen', en: 'Settings' },
signOut: { nl: 'Uitloggen', en: 'Sign Out' },
},
// Common UI
common: {
loading: { nl: 'Laden...', en: 'Loading...' },
error: { nl: 'Fout', en: 'Error' },
search: { nl: 'Zoeken', en: 'Search' },
filter: { nl: 'Filteren', en: 'Filter' },
save: { nl: 'Opslaan', en: 'Save' },
cancel: { nl: 'Annuleren', en: 'Cancel' },
close: { nl: 'Sluiten', en: 'Close' },
back: { nl: 'Terug', en: 'Back' },
next: { nl: 'Volgende', en: 'Next' },
previous: { nl: 'Vorige', en: 'Previous' },
results: { nl: 'resultaten', en: 'results' },
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
total: { nl: 'Totaal', en: 'Total' },
hours: { nl: 'uren', en: 'hours' },
week: { nl: 'week', en: 'week' },
status: { nl: 'Status', en: 'Status' },
},
// Project Plan specific
projectPlan: {
title: { nl: 'Projectplan', en: 'Project Plan' },
timeline: { nl: 'Tijdlijn', en: 'Timeline' },
workPackages: { nl: 'Werkpakketten', en: 'Work Packages' },
ontologies: { nl: 'Ontologieën', en: 'Ontologies' },
outOfScope: { nl: 'Buiten scope', en: 'Out of Scope' },
totalHours: { nl: 'Totaal uren', en: 'Total Hours' },
deliverables: { nl: 'Deliverables', en: 'Deliverables' },
ontologyAlignments: { nl: 'Ontologie verbindingen', en: 'Ontology Alignments' },
hoursPerWP: { nl: 'Uren per werkpakket', en: 'Hours per Work Package' },
lastUpdated: { nl: 'Laatst bijgewerkt', en: 'Last updated' },
commissioner: { nl: 'Opdrachtgever', en: 'Commissioner' },
notIncluded: { nl: 'Niet inbegrepen in dit project', en: 'Not Included in This Project' },
futureScope: { nl: 'Toekomstig', en: 'Future' },
rationale: { nl: 'Reden', en: 'Rationale' },
ontologyNetwork: { nl: 'Ontologie Afstemming Netwerk', en: 'Ontology Alignment Network' },
networkDescription: {
nl: 'Visualisatie van de verbindingen tussen de Bronhouder Ontologie en bestaande ontologieën.',
en: 'Visualization of connections between the Heritage Custodian Ontology and existing ontologies.'
},
extension: { nl: 'Uitbreiding', en: 'Extension' },
integration: { nl: 'Integratie', en: 'Integration' },
mapping: { nl: 'Mapping', en: 'Mapping' },
},
// Visualize page
visualize: {
title: { nl: 'Schema Visualisatie', en: 'Schema Visualization' },
selectDiagram: { nl: 'Selecteer diagram', en: 'Select diagram' },
zoom: { nl: 'Zoom', en: 'Zoom' },
reset: { nl: 'Reset', en: 'Reset' },
export: { nl: 'Exporteren', en: 'Export' },
},
// Database page
database: {
title: { nl: 'Database Verkenner', en: 'Database Explorer' },
tables: { nl: 'Tabellen', en: 'Tables' },
records: { nl: 'Records', en: 'Records' },
columns: { nl: 'Kolommen', en: 'Columns' },
},
// Map page
map: {
title: { nl: 'Erfgoedinstellingen Kaart', en: 'Heritage Institutions Map' },
institutions: { nl: 'Instellingen', en: 'Institutions' },
province: { nl: 'Provincie', en: 'Province' },
city: { nl: 'Plaats', en: 'City' },
type: { nl: 'Type', en: 'Type' },
},
// Stats page
stats: {
title: { nl: 'Statistieken', en: 'Statistics' },
byProvince: { nl: 'Per provincie', en: 'By Province' },
byType: { nl: 'Per type', en: 'By Type' },
overview: { nl: 'Overzicht', en: 'Overview' },
},
// Settings page
settings: {
title: { nl: 'Instellingen', en: 'Settings' },
language: { nl: 'Taal', en: 'Language' },
theme: { nl: 'Thema', en: 'Theme' },
darkMode: { nl: 'Donkere modus', en: 'Dark Mode' },
notifications: { nl: 'Notificaties', en: 'Notifications' },
},
} as const;
// Helper to get translation from the translations object
export function getTranslation(
key: keyof typeof translations,
subKey: string,
language: Language
): string {
const section = translations[key] as Record<string, { nl: string; en: string }>;
const item = section[subKey];
if (!item) return subKey;
return language === 'en' ? item.en : item.nl;
}

View file

@ -21,8 +21,6 @@ import {
LinearProgress,
Tabs,
Tab,
ToggleButton,
ToggleButtonGroup,
Accordion,
AccordionSummary,
AccordionDetails,
@ -52,6 +50,7 @@ import {
} from '@mui/icons-material';
import * as d3 from 'd3';
import yaml from 'js-yaml';
import { useLanguage } from '../contexts/LanguageContext';
import './ProjectPlanPage.css';
// NDE-inspired theme
@ -194,9 +193,9 @@ interface ProjectPlan {
modified_date: string;
}
// Helper to get bilingual text (using Language type from context)
type Language = 'nl' | 'en';
// Helper to get bilingual text
const getText = (nl: string, en: string | undefined, lang: Language): string => {
return lang === 'en' && en ? en : nl;
};
@ -235,7 +234,7 @@ export default function ProjectPlanPage() {
const [projectData, setProjectData] = useState<ProjectPlan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [language, setLanguage] = useState<Language>('nl');
const { language } = useLanguage(); // Use global language context
const [activeTab, setActiveTab] = useState(0);
const timelineRef = useRef<SVGSVGElement>(null);
const networkRef = useRef<SVGSVGElement>(null);
@ -504,10 +503,6 @@ export default function ProjectPlanPage() {
}
}, [projectData, activeTab, drawTimeline, drawNetwork]);
const handleLanguageChange = (_: React.MouseEvent<HTMLElement>, newLang: Language | null) => {
if (newLang) setLanguage(newLang);
};
if (loading) {
return (
<ThemeProvider theme={ndeTheme}>
@ -550,15 +545,6 @@ export default function ProjectPlanPage() {
{getText(projectData.plan_description, projectData.plan_description_en, language).substring(0, 200)}...
</Typography>
</Box>
<ToggleButtonGroup
value={language}
exclusive
onChange={handleLanguageChange}
size="small"
>
<ToggleButton value="nl">NL</ToggleButton>
<ToggleButton value="en">EN</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Summary Cards */}
@ -834,17 +820,6 @@ export default function ProjectPlanPage() {
</Card>
)}
{/* Footer */}
<Box mt={4} textAlign="center">
<Typography variant="caption" color="text.secondary">
{language === 'nl' ? 'Laatst bijgewerkt: ' : 'Last updated: '}{projectData.modified_date}
{' | '}
{language === 'nl' ? 'Opdrachtgever: ' : 'Commissioner: '}
<a href={projectData.commissioning_organization.agent_url} target="_blank" rel="noopener noreferrer">
{projectData.commissioning_organization.agent_name}
</a>
</Typography>
</Box>
</Container>
</Box>
</ThemeProvider>

View file

@ -255,6 +255,112 @@
cursor: not-allowed;
}
/* Generate Section */
.generate-section {
padding: 1rem 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.generate-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1rem 0;
font-size: 0.9375rem;
font-weight: 600;
color: #172a59;
}
.generate-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.generate-group {
padding: 1rem;
background: white;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.generate-group h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #172a59;
}
.generate-desc {
margin: 0 0 0.75rem 0;
font-size: 0.75rem;
color: #666;
line-height: 1.4;
}
.generate-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, #4a7dff 0%, #2c5ce6 100%);
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.generate-button:hover:not(:disabled) {
background: linear-gradient(135deg, #2c5ce6 0%, #1a4cbb 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 125, 255, 0.3);
}
.generate-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.generate-button--disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
cursor: not-allowed;
}
.generate-button--disabled:hover {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
transform: none;
box-shadow: none;
}
.generate-hint {
margin: 0.5rem 0 0 0;
font-size: 0.6875rem;
color: #999;
font-style: italic;
}
/* Spinning animation for loading state */
@keyframes spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinning {
animation: spinning 1s linear infinite;
}
/* Available Schemas Section */
.schemas-section {
border-bottom: 1px solid #e0e0e0;

View file

@ -14,20 +14,13 @@ import { parseUMLDiagramWithDetails } from '@/components/uml/UMLParser';
import type { GraphNode, GraphLink } from '@/types/rdf';
import type { UMLDiagram, DagreDirection, DagreRanker } from '@/components/uml/UMLVisualization';
import {
Search, Menu, X, Download, Image, FileCode, Code, ChevronDown, Upload, FileText
Menu, X, Download, Image, FileCode, Code, ChevronDown, Upload, FileText, RefreshCw, Database
} from 'lucide-react';
import './Visualize.css';
// File type definitions
type SchemaFormat = 'turtle' | 'n-triples' | 'jsonld' | 'mermaid' | 'erdiagram' | 'plantuml' | 'graphviz';
interface SchemaFile {
name: string;
path: string;
format: SchemaFormat;
category: 'rdf' | 'uml';
}
// Detect format from file extension
function detectFormat(filename: string): { format: SchemaFormat; category: 'rdf' | 'uml' } {
const ext = filename.toLowerCase().split('.').pop() || '';
@ -125,9 +118,7 @@ export function Visualize() {
// File and format state
const [fileName, setFileName] = useState<string>(persistedState.current?.fileName || '');
const [currentCategory, setCurrentCategory] = useState<'rdf' | 'uml' | null>(persistedState.current?.currentCategory || null);
const [schemasSectionExpanded, setSchemasSectionExpanded] = useState<boolean>(false);
const [loadSectionExpanded, setLoadSectionExpanded] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const [customInput, setCustomInput] = useState<string>('');
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true);
@ -177,136 +168,9 @@ export function Visualize() {
const [umlError, setUmlError] = useState<string | null>(null);
const isLoading = dbLoading || parserLoading || umlLoading;
// Available schema files - combined RDF and UML
const availableSchemas: SchemaFile[] = [
// RDF Schemas
{
name: 'Custodian with Ontology Mappings (Latest)',
path: '/schemas/20251121/rdf/custodian_with_ontology_mappings_20251126_193334.owl.ttl',
format: 'turtle',
category: 'rdf'
},
{
name: 'Custodian with Auxiliary Classes',
path: '/schemas/20251121/rdf/custodian_with_auxiliary_classes_20251126_101032.owl.ttl',
format: 'turtle',
category: 'rdf'
},
{
name: 'Custodian with Digital Platform',
path: '/schemas/20251121/rdf/custodian_with_digital_platform_20251125_115124.owl.ttl',
format: 'turtle',
category: 'rdf'
},
{
name: 'Custodian Name Modular',
path: '/schemas/20251121/rdf/01_custodian_name_modular_20251124_002122.owl.ttl',
format: 'turtle',
category: 'rdf'
},
{
name: 'Custodian Multi-Aspect',
path: '/schemas/20251121/rdf/custodian_multi_aspect_20251122_155319.owl.ttl',
format: 'turtle',
category: 'rdf'
},
{
name: 'Encompassing Body',
path: '/schemas/20251121/rdf/EncompassingBody_20251123_232811.owl.ttl',
format: 'turtle',
category: 'rdf'
},
// UML Diagrams - Mermaid Class
{
name: 'Custodian Multi-Aspect (Mermaid Class)',
path: '/schemas/20251121/uml/mermaid/custodian_multi_aspect_20251122_155319.mmd',
format: 'mermaid',
category: 'uml'
},
{
name: 'Custodian Name Modular (Mermaid YuML)',
path: '/schemas/20251121/uml/mermaid/01_custodian_name_modular_20251122_182317_yuml.mmd',
format: 'mermaid',
category: 'uml'
},
// UML Diagrams - ER
{
name: 'Custodian ER Diagram',
path: '/schemas/20251121/uml/erdiagram/custodian_multi_aspect_20251122_171249.mmd',
format: 'erdiagram',
category: 'uml'
},
{
name: 'Custodian Name Modular (ER)',
path: '/schemas/20251121/uml/mermaid/01_custodian_name_modular_20251122_205118_er.mmd',
format: 'erdiagram',
category: 'uml'
},
{
name: 'Custodian with Ontology Mappings (ER)',
path: '/schemas/20251121/uml/mermaid/custodian_with_ontology_mappings_20251126_204859_er.mmd',
format: 'erdiagram',
category: 'uml'
},
{
name: 'Custodian with Inheritance (ER)',
path: '/schemas/20251121/uml/mermaid/custodian_with_inheritance_20251127_211317.mmd',
format: 'erdiagram',
category: 'uml'
},
// UML Diagrams - PlantUML
{
name: 'Custodian Multi-Aspect (PlantUML)',
path: '/schemas/20251121/uml/plantuml/custodian_multi_aspect_20251122_155319.puml',
format: 'plantuml',
category: 'uml'
},
// UML Diagrams - GraphViz
{
name: 'Custodian Multi-Aspect (GraphViz)',
path: '/schemas/20251121/uml/graphviz/custodian_multi_aspect_20251122_155319.dot',
format: 'graphviz',
category: 'uml'
},
];
// Filter schemas based on search
const filteredSchemas = availableSchemas.filter(schema =>
schema.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Group schemas by category
const rdfSchemas = filteredSchemas.filter(s => s.category === 'rdf');
const umlSchemas = filteredSchemas.filter(s => s.category === 'uml');
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (exportDropdownRef.current && !exportDropdownRef.current.contains(event.target as Node)) {
setExportDropdownOpen(false);
}
if (layoutDropdownRef.current && !layoutDropdownRef.current.contains(event.target as Node)) {
setLayoutDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Persist state to sessionStorage whenever relevant state changes
useEffect(() => {
saveStateToSession({
fileName,
currentCategory,
umlFileContent,
umlDiagram,
});
}, [fileName, currentCategory, umlFileContent, umlDiagram]);
// Generator state
const [generatingUml, setGeneratingUml] = useState(false);
const [_generatingRdf, setGeneratingRdf] = useState(false);
// Clear current visualization
const clearVisualization = useCallback(() => {
@ -316,22 +180,6 @@ export function Visualize() {
// Reset RDF graph data would require loadGraphData with empty result
}, []);
// Load RDF content
const loadRdfContent = useCallback(
async (content: string, format: 'turtle' | 'n-triples' | 'jsonld') => {
clearVisualization();
setCurrentCategory('rdf');
const mimeType = format === 'turtle' ? 'text/turtle' :
format === 'n-triples' ? 'application/n-triples' :
'application/ld+json';
const result = await parse(content, mimeType);
loadGraphData(result);
},
[parse, loadGraphData, clearVisualization]
);
// Load UML content
const loadUmlContent = useCallback(
(content: string, name: string) => {
@ -372,6 +220,88 @@ export function Visualize() {
[clearVisualization]
);
// Generate UML from LinkML schema (fetches pre-generated file)
const handleGenerateUml = useCallback(async () => {
setGeneratingUml(true);
setUmlError(null);
try {
// Fetch the pre-generated Mermaid file from public folder
const response = await fetch('/data/heritage_custodian_ontology.mmd');
if (!response.ok) {
throw new Error(`Failed to load UML diagram: ${response.statusText}`);
}
const mermaidContent = await response.text();
if (mermaidContent) {
setFileName('Heritage Custodian Ontology (Generated)');
loadUmlContent(mermaidContent, 'Heritage Custodian Ontology');
} else {
throw new Error('No Mermaid diagram content found');
}
} catch (err) {
console.error('Error loading UML:', err);
setUmlError(err instanceof Error ? err.message : 'Failed to load UML diagram');
} finally {
setGeneratingUml(false);
}
}, [loadUmlContent]);
// Generate RDF overview (placeholder for future implementation)
const handleGenerateRdf = useCallback(async () => {
setGeneratingRdf(true);
try {
// Placeholder - will be implemented after converting enriched entries to instances
setUmlError('RDF generation coming soon! This feature will be available after converting enriched entries to Heritage Custodian Ontology instances.');
} finally {
setGeneratingRdf(false);
}
}, []);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (exportDropdownRef.current && !exportDropdownRef.current.contains(event.target as Node)) {
setExportDropdownOpen(false);
}
if (layoutDropdownRef.current && !layoutDropdownRef.current.contains(event.target as Node)) {
setLayoutDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Persist state to sessionStorage whenever relevant state changes
useEffect(() => {
saveStateToSession({
fileName,
currentCategory,
umlFileContent,
umlDiagram,
});
}, [fileName, currentCategory, umlFileContent, umlDiagram]);
// Load RDF content
const loadRdfContent = useCallback(
async (content: string, format: 'turtle' | 'n-triples' | 'jsonld') => {
clearVisualization();
setCurrentCategory('rdf');
const mimeType = format === 'turtle' ? 'text/turtle' :
format === 'n-triples' ? 'application/n-triples' :
'application/ld+json';
const result = await parse(content, mimeType);
loadGraphData(result);
},
[parse, loadGraphData, clearVisualization]
);
// Handle file upload (unified)
const handleFileUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -405,33 +335,6 @@ export function Visualize() {
[loadRdfContent, loadUmlContent]
);
// Handle loading predefined schema file
const handleLoadSchemaFile = useCallback(
async (schema: SchemaFile) => {
setFileName(schema.name);
try {
const response = await fetch(schema.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`);
}
const content = await response.text();
if (schema.category === 'rdf') {
await loadRdfContent(content, schema.format as 'turtle' | 'n-triples' | 'jsonld');
} else {
loadUmlContent(content, schema.name);
}
setSchemasSectionExpanded(false);
} catch (err) {
console.error('Failed to load schema file:', err);
}
},
[loadRdfContent, loadUmlContent]
);
// Handle custom input (paste)
const handleLoadCustomInput = useCallback(async () => {
if (!customInput.trim()) return;
@ -563,27 +466,6 @@ export function Visualize() {
</button>
</div>
{/* Search Bar */}
<div className="search-section">
<Search size={16} className="search-icon" />
<input
type="text"
placeholder="Search schemas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => setSearchQuery('')}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
{/* Load Data Section */}
<div className="load-section">
<button
@ -639,82 +521,59 @@ export function Visualize() {
)}
</div>
{/* Available Schemas */}
<div className="schemas-section">
<button
className="schemas-header"
onClick={() => setSchemasSectionExpanded(!schemasSectionExpanded)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
<span>Available Schemas</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width="16"
height="16"
className={`chevron ${schemasSectionExpanded ? 'expanded' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/* Generate Section */}
<div className="generate-section">
<h3 className="generate-header">
<RefreshCw size={16} />
<span>Generate Visualizations</span>
</h3>
{schemasSectionExpanded && (
<div className="schemas-list">
{/* RDF Schemas */}
{rdfSchemas.length > 0 && (
<div className="schema-group">
<h4>RDF Schemas</h4>
{rdfSchemas.map((schema, index) => (
<button
key={`rdf-${index}`}
className={`schema-button ${fileName === schema.name ? 'active' : ''}`}
onClick={() => handleLoadSchemaFile(schema)}
disabled={isLoading}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
{schema.name}
</button>
))}
</div>
)}
{/* UML Schemas */}
{umlSchemas.length > 0 && (
<div className="schema-group">
<h4>UML Diagrams</h4>
{umlSchemas.map((schema, index) => (
<button
key={`uml-${index}`}
className={`schema-button ${fileName === schema.name ? 'active' : ''}`}
onClick={() => handleLoadSchemaFile(schema)}
disabled={isLoading}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="9" x2="15" y2="9" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
{schema.name}
</button>
))}
</div>
)}
{filteredSchemas.length === 0 && (
<p className="no-results">No schemas found for "{searchQuery}"</p>
)}
<div className="generate-content">
{/* UML Diagram Generator */}
<div className="generate-group">
<h4>UML Diagram</h4>
<p className="generate-desc">
Generate from LinkML schema (Heritage Custodian Ontology)
</p>
<button
className="generate-button"
onClick={handleGenerateUml}
disabled={generatingUml || isLoading}
>
{generatingUml ? (
<>
<RefreshCw size={16} className="spinning" />
Generating...
</>
) : (
<>
<RefreshCw size={16} />
Generate UML
</>
)}
</button>
</div>
)}
{/* RDF Overview Generator */}
<div className="generate-group">
<h4>RDF Overview</h4>
<p className="generate-desc">
Generate RDF graph of all heritage custodians
</p>
<button
className="generate-button generate-button--disabled"
onClick={handleGenerateRdf}
disabled={true}
title="Coming soon after converting enriched entries to ontology instances"
>
<Database size={16} />
Coming Soon
</button>
<p className="generate-hint">
Available after converting enriched entries to instances
</p>
</div>
</div>
</div>
{/* Current File */}

View file

@ -9,7 +9,7 @@ imports:
- linkml:types
- ../metadata
- ../enums/AppellationTypeEnum
- CustodianName
- ./CustodianName
classes:

View file

@ -4,7 +4,7 @@ title: Archive Organization Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/access_policy
classes:

View file

@ -3,7 +3,7 @@ name: BioCustodianType
title: Biological and Zoological Custodian Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/collection_size
classes:

View file

@ -34,7 +34,7 @@ imports:
- linkml:types
- ../enums/CallForApplicationStatusEnum
- ../enums/FundingRequirementTypeEnum
- FundingRequirement
- ./FundingRequirement
- ../slots/contact_email
- ../slots/keywords

View file

@ -14,6 +14,7 @@ imports:
- ./CustodianObservation
- ./ReconstructionActivity
- ./TimeSpan
- ../slots/documentation_url
prefixes:
linkml: https://w3id.org/linkml/
@ -701,9 +702,7 @@ slots:
description: Vendor website URL
range: uri
documentation_url:
description: Documentation URL
range: uri
# NOTE: documentation_url imported from global slot ../slots/documentation_url.yaml
repository_url:
description: Source code repository URL

View file

@ -18,6 +18,7 @@ imports:
- ./Storage
- ../enums/ArchiveProcessingStatusEnum
- ../slots/access_restrictions
- ../slots/storage_location
prefixes:
linkml: https://w3id.org/linkml/
@ -680,9 +681,8 @@ slots:
description: Estimated physical/digital extent
range: string
storage_location:
description: Physical storage location(s)
range: Storage
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
tracked_in_cms:
description: CMS tracking this accession

View file

@ -24,6 +24,7 @@ imports:
- ../slots/oai_pmh_endpoint
- ../slots/platform_type
- ../slots/platform_name
- ../slots/storage_location
prefixes:
linkml: https://w3id.org/linkml/
@ -776,10 +777,8 @@ slots:
# NOTE: preservation_level imported from global slot ../slots/preservation_level.yaml
storage_location:
slot_uri: premis:storedAt
description: Primary storage location for digital content
range: string
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
fixity_check_date:
slot_uri: premis:fixity

View file

@ -37,7 +37,7 @@ see_also:
- https://www.wikidata.org/wiki/Q132560468 # university archive
imports:
- CustodianType
- ./CustodianType
prefixes:
hc: https://nde.nl/ontology/hc/

View file

@ -4,7 +4,7 @@ title: Gallery Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
GalleryType:

View file

@ -25,6 +25,10 @@ prefixes:
imports:
- linkml:types
- ../metadata
- ../slots/geonames_id
- ../slots/latitude
- ../slots/longitude
- ../slots/altitude
types:
WktLiteral:
@ -43,39 +47,9 @@ slots:
range: uriorcurie
description: "Unique identifier for this geospatial place"
latitude:
range: float
slot_uri: wgs84:lat
description: >-
WGS84 latitude coordinate (decimal degrees).
Positive = North, Negative = South.
minimum_value: -90.0
maximum_value: 90.0
examples:
- value: 52.3600
description: "Amsterdam latitude"
longitude:
range: float
slot_uri: wgs84:long
description: >-
WGS84 longitude coordinate (decimal degrees).
Positive = East, Negative = West.
minimum_value: -180.0
maximum_value: 180.0
examples:
- value: 4.8852
description: "Amsterdam longitude"
altitude:
range: float
slot_uri: wgs84:alt
description: >-
Altitude above sea level (meters).
Optional - use for elevated or underground locations.
examples:
- value: -2.0
description: "Amsterdam (below sea level)"
# NOTE: latitude imported from global slot ../slots/latitude.yaml
# NOTE: longitude imported from global slot ../slots/longitude.yaml
# NOTE: altitude imported from global slot ../slots/altitude.yaml
geometry_wkt:
range: string
@ -119,22 +93,7 @@ slots:
- value: "EPSG:28992"
description: "Dutch Rijksdriehoeksstelsel"
geonames_id:
range: integer
slot_uri: geonames:geonameId
description: >-
GeoNames numeric identifier.
Resolves to https://www.geonames.org/{id}/
Use for:
- Linking to GeoNames knowledge base
- Disambiguating place names (41 "Springfield"s in USA)
- Accessing hierarchical administrative data
examples:
- value: 2759794
description: "Amsterdam (GeoNames ID)"
- value: 6930126
description: "Rijksmuseum building"
# NOTE: geonames_id imported from global slot ../slots/geonames_id.yaml
osm_id:
range: string

View file

@ -16,6 +16,8 @@ imports:
- ./TimeSpan
- ../enums/GiftShopTypeEnum
- ../enums/ProductCategoryEnum
- ../slots/staff_count
- ../slots/managed_by
prefixes:
linkml: https://w3id.org/linkml/
@ -747,17 +749,13 @@ slots:
description: Visitor to purchase conversion rate
range: float
staff_count:
description: Number of shop staff
range: integer
# NOTE: staff_count imported from global slot ../slots/staff_count.yaml
square_meters:
description: Retail floor space
range: float
managed_by:
description: Management structure
range: string
# NOTE: managed_by imported from global slot ../slots/managed_by.yaml
supplier_relationships:
description: Key supplier relationships

View file

@ -39,7 +39,7 @@ see_also:
- https://www.wikidata.org/wiki/Q15755503 # archaeological society
imports:
- CustodianType
- ./CustodianType
prefixes:
hc: https://nde.nl/ontology/hc/

View file

@ -4,7 +4,7 @@ title: Library Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/cataloging_standard
classes:

View file

@ -4,7 +4,7 @@ title: Museum Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
- ../slots/collection_focus
- ../slots/cataloging_standard

View file

@ -4,7 +4,7 @@ title: Official Institution Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
OfficialInstitutionType:

View file

@ -34,6 +34,7 @@ imports:
- ../slots/funding_source
- ../slots/contact_email
- ../slots/keywords
- ../slots/documentation_url
default_prefix: hc
@ -86,9 +87,7 @@ slots:
range: uriorcurie
multivalued: true
description: Related or predecessor/successor projects
documentation_url:
range: uri
description: URL to project documentation
# NOTE: documentation_url imported from global slot ../slots/documentation_url.yaml
# NOTE: contact_email imported from global slot ../slots/contact_email.yaml

View file

@ -9,10 +9,10 @@ imports:
- linkml:types
- ../metadata
- ../enums/ReconstructionActivityTypeEnum
- ReconstructionAgent
- TimeSpan
- CustodianObservation
- ConfidenceMeasure
- ./ReconstructionAgent
- ./TimeSpan
- ./CustodianObservation
- ./ConfidenceMeasure
classes:

View file

@ -34,6 +34,7 @@ imports:
- ../metadata
- ./TimeSpan
- ./Jurisdiction
- ./RegistrationAuthority
- ../slots/jurisdiction
- ../slots/description
- ../slots/website
@ -135,157 +136,8 @@ classes:
range: TimeSpan
required: true
RegistrationAuthority:
class_uri: gleif-base:RegistrationAuthority
description: >-
Authority that maintains official registrations of organizations.
**Ontology Alignment:**
- gleif-base:RegistrationAuthority - "An organization that is responsible for
maintaining a registry and provides registration services."
A RegistrationAuthority is the **organization** that maintains one or more
trade registers, distinct from the TradeRegister itself (the database/system).
**Key Distinction:**
- RegistrationAuthority: The organization (e.g., "Kamer van Koophandel", "Companies House")
- TradeRegister: The register/database (e.g., "Handelsregister", "Companies Register")
**Examples:**
- Netherlands: Kamer van Koophandel (KvK) - GLEIF RA000439
- UK: Companies House - GLEIF RA000585
- Germany: Amtsgericht München (local court) - GLEIF RA000385
- Japan: Legal Affairs Bureau (法務局) - GLEIF RA000429
- Ireland: Companies Registration Office (CRO) - GLEIF RA000421
**GLEIF Integration:**
GLEIF maintains the Registration Authorities List (RAL) with 1,050+ authorities.
Each authority has a unique RA code (format: RA followed by 6 digits).
Reference: https://www.gleif.org/en/about-lei/code-lists/registration-authorities-list
See also:
- TradeRegister: Registers maintained by this authority
- Jurisdiction: Geographic/legal scope of the authority
- RegistrationNumber: Numbers issued through this authority's registers
exact_mappings:
- gleif-base:RegistrationAuthority
close_mappings:
- org:Organization
- schema:GovernmentOrganization
related_mappings:
- rov:hasRegisteredOrganization
attributes:
id:
identifier: true
slot_uri: schema:identifier
description: Unique identifier for the registration authority
range: uriorcurie
required: true
name:
slot_uri: gleif-base:hasNameTranslatedEnglish
description: >-
Official name of the registration authority in English.
gleif-base:hasNameTranslatedEnglish - "The name used to refer to a person
or organization, translated into English."
Examples:
- "Chamber of Commerce" (Netherlands)
- "Companies House" (UK)
- "Legal Affairs Bureau" (Japan)
range: string
required: true
name_local:
slot_uri: gleif-base:hasNameLegalLocal
description: >-
Official name in local language.
gleif-base:hasNameLegalLocal - "The name used to refer to an person or
organization in legal communications in local alphabet"
Examples:
- "Kamer van Koophandel" (Dutch)
- "法務局" (Japanese)
- "Amtsgericht" (German)
range: string
abbreviation:
slot_uri: gleif-base:hasAbbreviationLocal
description: >-
Common abbreviation.
gleif-base:hasAbbreviationLocal - "An abbreviation using a language local
to the entity identified"
Examples: "KvK", "CH", "CRO"
range: string
jurisdiction:
slot_uri: gleif-base:hasCoverageArea
description: >-
Geographic/legal jurisdiction of the authority.
gleif-base:hasCoverageArea - "Indicates a geographic region in which some
service is provided, or to which some policy applies"
Links to Jurisdiction class.
range: Jurisdiction
required: true
inlined: true
gleif_ra_code:
slot_uri: schema:identifier
description: >-
GLEIF Registration Authority code.
Format: "RA" followed by 6 digits
Examples:
- RA000439: Netherlands KvK
- RA000585: UK Companies House
- RA000385: Germany Amtsgericht München
Reference: https://www.gleif.org/en/about-lei/code-lists/registration-authorities-list
range: string
pattern: "^RA[0-9]{6}$"
registers:
slot_uri: gleif-base:isManagedBy
description: >-
Trade registers maintained by this authority.
Inverse of TradeRegister.maintained_by.
Examples:
- KvK maintains: Handelsregister
- Companies House maintains: Companies Register, LLP Register
range: TradeRegister
multivalued: true
inlined: false
website:
slot_uri: gleif-base:hasWebsite
description: >-
Official website of the registration authority.
gleif-base:hasWebsite - "A website associated with something"
range: uri
registration_types:
slot_uri: schema:knowsAbout
description: >-
Types of entities this authority can register.
Examples: ["companies", "charities", "foundations"]
range: string
multivalued: true
# NOTE: RegistrationAuthority class imported from ./RegistrationAuthority.yaml
# Contains the full definition of the authority class with GLEIF RA codes.
GovernanceStructure:
class_uri: org:hasUnit

View file

@ -4,7 +4,7 @@ title: Research Organization Type Classification
imports:
- linkml:types
- CustodianType
- ./CustodianType
classes:
ResearchOrganizationType:

View file

@ -14,8 +14,8 @@ title: Settlement Class
imports:
- linkml:types
- Country
- Subregion
- ./Country
- ./Subregion
- ../slots/country
- ../slots/subregion
- ../slots/geonames_id

View file

@ -37,6 +37,8 @@ imports:
- ./StorageConditionPolicy
- ../enums/StorageTypeEnum
- ../enums/StorageStandardEnum
- ../slots/storage_location
- ../slots/managed_by
classes:
Storage:
@ -451,9 +453,8 @@ slots:
description: Description of storage facility
range: string
storage_location:
description: Physical location (AuxiliaryPlace)
range: AuxiliaryPlace
# NOTE: storage_location imported from global slot ../slots/storage_location.yaml
# Use slot_usage in class to customize range
capacity_description:
description: Qualitative capacity description
@ -494,6 +495,4 @@ slots:
range: StorageCondition
multivalued: true
managed_by:
description: Managing organizational unit
range: string
# NOTE: managed_by imported from global slot ../slots/managed_by.yaml

View file

@ -15,7 +15,7 @@ title: Subregion Class
imports:
- linkml:types
- Country
- ./Country
- ../slots/country
classes:

View file

@ -0,0 +1,24 @@
# Global Slot: altitude
# Altitude above sea level in meters
id: https://nde.nl/ontology/hc/slot/altitude
name: altitude-slot
title: Altitude Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
wgs84: http://www.w3.org/2003/01/geo/wgs84_pos#
imports:
- linkml:types
slots:
altitude:
slot_uri: wgs84:alt
range: float
description: >-
Altitude above sea level (meters).
Optional - use for elevated or underground locations.
exact_mappings:
- wgs84:alt

View file

@ -0,0 +1,22 @@
# Global Slot: documentation_url
# URL to documentation for a project, system, or resource
id: https://nde.nl/ontology/hc/slot/documentation_url
name: documentation-url-slot
title: Documentation URL Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
schema: http://schema.org/
imports:
- linkml:types
slots:
documentation_url:
slot_uri: schema:documentation
description: URL to documentation for this entity
range: uri
exact_mappings:
- schema:documentation

View file

@ -0,0 +1,22 @@
# Global Slot: managed_by
# Identifies the entity or organizational unit managing something
id: https://nde.nl/ontology/hc/slot/managed_by
name: managed-by-slot
title: Managed By Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
org: http://www.w3.org/ns/org#
imports:
- linkml:types
slots:
managed_by:
slot_uri: org:linkedTo
description: Entity or organizational unit managing this resource
range: string
exact_mappings:
- org:linkedTo

View file

@ -0,0 +1,24 @@
# Global Slot: storage_location
# Physical or logical location where materials are stored
id: https://nde.nl/ontology/hc/slot/storage_location
name: storage-location-slot
title: Storage Location Slot
prefixes:
linkml: https://w3id.org/linkml/
hc: https://nde.nl/ontology/hc/
premis: http://www.loc.gov/standards/premis/rdf/v3/
imports:
- linkml:types
slots:
storage_location:
slot_uri: premis:storedAt
description: >-
Physical or logical location where materials are stored.
Range varies by context - can be AuxiliaryPlace, Storage, or string.
range: uriorcurie
exact_mappings:
- premis:storedAt

View file

@ -0,0 +1,689 @@
#!/usr/bin/env python3
"""
Enrich NDE Heritage Institution Entries with GHCID Persistent Identifiers.
This script:
1. Loads all YAML files from data/nde/enriched/entries/
2. Extracts location data (city, region, coordinates)
3. Generates base GHCIDs using NL-REGION-CITY-TYPE-ABBREV format
4. Detects collisions and applies First Batch rule (all get name suffixes)
5. Generates all 4 identifier formats:
- Human-readable GHCID string
- UUID v5 (SHA-1, RFC 4122 compliant) - PRIMARY
- UUID v8 (SHA-256, SOTA cryptographic strength) - Future-proof
- Numeric (64-bit integer for database PKs)
6. Adds GHCID fields to each entry
7. Generates collision statistics report
## GHCID Format
Base: NL-{Region}-{City}-{Type}-{Abbreviation}
With collision suffix: NL-{Region}-{City}-{Type}-{Abbreviation}-{name_suffix}
## Collision Resolution (First Batch Rule)
Since this is a batch import (all entries processed together), when multiple
institutions generate the same base GHCID:
- ALL colliding institutions receive native language name suffixes
- Name suffix: snake_case of institution name
Example:
- Two societies with NL-OV-ZWO-S-HK both become:
- NL-OV-ZWO-S-HK-historische_kring_zwolle
- NL-OV-ZWO-S-HK-heemkundige_kring_zwolle
Usage:
python scripts/enrich_nde_entries_ghcid.py [--dry-run]
Options:
--dry-run Preview changes without writing to files
"""
import argparse
import json
import re
import sys
import unicodedata
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import yaml
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from glam_extractor.identifiers.ghcid import (
GHCIDComponents,
GHCIDGenerator,
InstitutionType,
extract_abbreviation_from_name,
normalize_city_name,
)
# Dutch province to ISO 3166-2 code mapping
DUTCH_PROVINCE_CODES = {
# Standard names
"drenthe": "DR",
"flevoland": "FL",
"friesland": "FR",
"fryslan": "FR",
"fryslân": "FR",
"gelderland": "GE",
"groningen": "GR",
"limburg": "LI",
"noord-brabant": "NB",
"north brabant": "NB",
"noord brabant": "NB",
"noord-holland": "NH",
"north holland": "NH",
"noord holland": "NH",
"overijssel": "OV",
"utrecht": "UT",
"zeeland": "ZE",
"zuid-holland": "ZH",
"south holland": "ZH",
"zuid holland": "ZH",
}
# Institution type code mapping (from original entry 'type' field)
TYPE_CODE_MAP = {
"G": "G", # Gallery
"L": "L", # Library
"A": "A", # Archive
"M": "M", # Museum
"O": "O", # Official Institution
"R": "R", # Research Center
"C": "C", # Corporation
"U": "U", # Unknown
"B": "B", # Botanical/Zoo
"E": "E", # Education Provider
"S": "S", # Collecting Society
"P": "P", # Personal Collection
"F": "F", # Features (monuments, etc.)
"I": "I", # Intangible Heritage Group
"X": "X", # Mixed
"H": "H", # Holy Sites
"D": "D", # Digital Platform
"N": "N", # NGO
"T": "T", # Taste/Smell Heritage
}
def get_region_code(region_name: Optional[str]) -> str:
"""
Get ISO 3166-2 region code for a Dutch province.
Args:
region_name: Province/region name (Dutch or English)
Returns:
2-letter region code or "00" if not found
"""
if not region_name:
return "00"
# Normalize: lowercase, remove accents
normalized = unicodedata.normalize('NFD', region_name.lower())
normalized = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn')
normalized = normalized.strip()
return DUTCH_PROVINCE_CODES.get(normalized, "00")
def get_city_code(city_name: str) -> str:
"""
Generate 3-letter city code from city name.
Rules:
1. Single word: first 3 letters uppercase
2. City with article (de, het, den): first letter + first 2 of next word
3. Multi-word: first letter of each word (up to 3)
Args:
city_name: City name
Returns:
3-letter uppercase city code
"""
if not city_name:
return "XXX"
# Normalize: remove accents, handle special chars
normalized = normalize_city_name(city_name)
# Split into words
words = normalized.split()
if not words:
return "XXX"
# Dutch articles and prepositions
articles = {'de', 'het', 'den', "'s", 'op', 'aan', 'bij', 'ter'}
if len(words) == 1:
# Single word: take first 3 letters
code = words[0][:3].upper()
elif words[0].lower() in articles and len(words) > 1:
# City with article: first letter of article + first 2 of next word
code = (words[0][0] + words[1][:2]).upper()
else:
# Multi-word: take first letter of each word (up to 3)
code = ''.join(w[0] for w in words[:3]).upper()
# Ensure exactly 3 letters
if len(code) < 3:
code = code.ljust(3, 'X')
elif len(code) > 3:
code = code[:3]
# Ensure only A-Z characters
code = re.sub(r'[^A-Z]', 'X', code)
return code
def generate_name_suffix(institution_name: str) -> str:
"""
Generate snake_case name suffix from institution name.
Used for collision resolution. Converts native language name to
lowercase with underscores, removing diacritics and punctuation.
Args:
institution_name: Full institution name
Returns:
snake_case suffix (e.g., "historische_kring_zwolle")
"""
if not institution_name:
return "unknown"
# Normalize: NFD decomposition to remove accents
normalized = unicodedata.normalize('NFD', institution_name)
ascii_name = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn')
# Convert to lowercase
lowercase = ascii_name.lower()
# Remove apostrophes, commas, and other punctuation
no_punct = re.sub(r"[''`\",.:;!?()[\]{}]", '', lowercase)
# Replace spaces and hyphens with underscores
underscored = re.sub(r'[\s\-/]+', '_', no_punct)
# Remove any remaining non-alphanumeric characters (except underscores)
clean = re.sub(r'[^a-z0-9_]', '', underscored)
# Collapse multiple underscores
final = re.sub(r'_+', '_', clean).strip('_')
# Truncate if too long (max 50 chars for name suffix)
if len(final) > 50:
final = final[:50].rstrip('_')
return final if final else "unknown"
def extract_entry_data(entry: dict) -> dict:
"""
Extract relevant data from an entry for GHCID generation.
Looks in multiple sources for location data:
1. locations[] array (if already enriched)
2. original_entry.plaatsnaam_bezoekadres (NDE CSV city field)
3. google_maps_enrichment.address / city
4. museum_register_enrichment.province
5. wikidata_enrichment.wikidata_claims.location
Args:
entry: Entry dictionary from YAML
Returns:
Dict with: name, type_code, city, region, wikidata_id
"""
# Get institution name
name = None
if 'original_entry' in entry:
name = entry['original_entry'].get('organisatie')
if not name and 'wikidata_enrichment' in entry:
name = entry['wikidata_enrichment'].get('wikidata_label_nl')
if not name:
name = entry['wikidata_enrichment'].get('wikidata_label_en')
if not name:
name = "Unknown Institution"
# Get institution type
type_codes = []
if 'original_entry' in entry and 'type' in entry['original_entry']:
types = entry['original_entry']['type']
if isinstance(types, list):
type_codes = types
elif isinstance(types, str):
type_codes = [types]
# Use first type, default to U (Unknown)
type_code = type_codes[0] if type_codes else 'U'
# Get location - try multiple sources
city = None
region = None
# Source 1: locations[] array (already enriched)
if 'locations' in entry and entry['locations']:
loc = entry['locations'][0]
city = loc.get('city')
region = loc.get('region')
# Source 2: original_entry.plaatsnaam_bezoekadres (NDE CSV)
if not city and 'original_entry' in entry:
city = entry['original_entry'].get('plaatsnaam_bezoekadres')
# Source 3: google_maps_enrichment
if not city and 'google_maps_enrichment' in entry:
gm = entry['google_maps_enrichment']
# Try to extract city from address
address = gm.get('address', '')
if address:
# Dutch addresses: "Street Nr, Postcode City"
# Try to extract city from last part
parts = address.split(',')
if len(parts) >= 2:
last_part = parts[-1].strip()
# Remove postcode (4 digits + 2 letters)
import re
city_match = re.sub(r'^\d{4}\s*[A-Z]{2}\s*', '', last_part)
if city_match:
city = city_match
# Also try 'city' field if present
if not city:
city = gm.get('city')
# Source 4: museum_register_enrichment.province (for region)
if not region and 'museum_register_enrichment' in entry:
region = entry['museum_register_enrichment'].get('province')
# Source 5: wikidata_enrichment.wikidata_claims.location
if not city and 'wikidata_enrichment' in entry:
claims = entry['wikidata_enrichment'].get('wikidata_claims', {})
if 'location' in claims:
loc_data = claims['location']
if isinstance(loc_data, dict):
city = loc_data.get('label_en') or loc_data.get('label_nl')
# Source 6: Try wikidata description for city hint
if not city and 'wikidata_enrichment' in entry:
desc_nl = entry['wikidata_enrichment'].get('wikidata_description_nl', '')
# Try to extract city from "museum in [City], Nederland"
import re
city_match = re.search(r'in\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?),?\s*(?:Nederland|Netherlands)', desc_nl)
if city_match:
city = city_match.group(1)
# Get Wikidata ID
wikidata_id = None
if 'wikidata_enrichment' in entry:
wikidata_id = entry['wikidata_enrichment'].get('wikidata_entity_id')
if not wikidata_id and 'original_entry' in entry:
wikidata_id = entry['original_entry'].get('wikidata_id')
return {
'name': name,
'type_code': TYPE_CODE_MAP.get(type_code, 'U'),
'city': city,
'region': region,
'wikidata_id': wikidata_id,
}
def generate_base_ghcid(data: dict) -> Tuple[str, GHCIDComponents]:
"""
Generate base GHCID (without name suffix) for an institution.
Args:
data: Dict with name, type_code, city, region
Returns:
Tuple of (base_ghcid_string, GHCIDComponents)
"""
# Get region code
region_code = get_region_code(data['region'])
# Get city code
city_code = get_city_code(data['city']) if data['city'] else "XXX"
# Get abbreviation from name
abbreviation = extract_abbreviation_from_name(data['name'])
if not abbreviation:
abbreviation = "INST"
# Create components (without Wikidata QID - we'll use name suffix for collisions)
components = GHCIDComponents(
country_code="NL",
region_code=region_code,
city_locode=city_code,
institution_type=data['type_code'],
abbreviation=abbreviation,
wikidata_qid=None, # Don't use QID for collision resolution
)
return components.to_string(), components
def process_entries(entries_dir: Path, dry_run: bool = False) -> dict:
"""
Process all entry files and generate GHCIDs.
Args:
entries_dir: Path to entries directory
dry_run: If True, don't write changes
Returns:
Statistics dictionary
"""
stats = {
'total': 0,
'success': 0,
'skipped_no_location': 0,
'skipped_not_custodian': 0,
'collisions': 0,
'collision_groups': 0,
'files_updated': 0,
'errors': [],
}
# Timestamp for this batch
generation_timestamp = datetime.now(timezone.utc).isoformat()
# Phase 1: Load all entries and generate base GHCIDs
print("Phase 1: Loading entries and generating base GHCIDs...")
entries_data = [] # List of (filepath, entry, extracted_data, base_ghcid, components)
yaml_files = sorted(entries_dir.glob("*.yaml"))
stats['total'] = len(yaml_files)
for filepath in yaml_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
entry = yaml.safe_load(f)
if not entry:
continue
# Check if NOT_CUSTODIAN (skip these)
if entry.get('google_maps_status') == 'NOT_CUSTODIAN':
stats['skipped_not_custodian'] += 1
continue
# Extract data
data = extract_entry_data(entry)
# Check if we have location data
if not data['city']:
stats['skipped_no_location'] += 1
continue
# Generate base GHCID
base_ghcid, components = generate_base_ghcid(data)
entries_data.append({
'filepath': filepath,
'entry': entry,
'data': data,
'base_ghcid': base_ghcid,
'components': components,
})
except Exception as e:
stats['errors'].append(f"{filepath.name}: {str(e)}")
print(f" Loaded {len(entries_data)} entries with location data")
print(f" Skipped {stats['skipped_no_location']} entries without city")
print(f" Skipped {stats['skipped_not_custodian']} NOT_CUSTODIAN entries")
# Phase 2: Detect collisions
print("\nPhase 2: Detecting GHCID collisions...")
collision_groups = defaultdict(list)
for ed in entries_data:
collision_groups[ed['base_ghcid']].append(ed)
# Count collisions
for base_ghcid, group in collision_groups.items():
if len(group) > 1:
stats['collision_groups'] += 1
stats['collisions'] += len(group)
print(f" Found {stats['collision_groups']} collision groups ({stats['collisions']} entries)")
# Phase 3: Resolve collisions and generate final GHCIDs
print("\nPhase 3: Resolving collisions and generating final GHCIDs...")
collision_report = []
for base_ghcid, group in collision_groups.items():
if len(group) > 1:
# COLLISION: Apply First Batch rule - ALL get name suffixes
collision_report.append({
'base_ghcid': base_ghcid,
'count': len(group),
'institutions': [ed['data']['name'] for ed in group],
})
for ed in group:
# Generate name suffix
name_suffix = generate_name_suffix(ed['data']['name'])
ed['final_ghcid'] = f"{base_ghcid}-{name_suffix}"
ed['had_collision'] = True
else:
# No collision: use base GHCID
ed = group[0]
ed['final_ghcid'] = base_ghcid
ed['had_collision'] = False
# Phase 4: Generate all identifier formats and update entries
print("\nPhase 4: Generating identifier formats and updating entries...")
for ed in entries_data:
final_ghcid = ed['final_ghcid']
# Create final components with the resolved GHCID string
# We need to parse it back or generate UUIDs directly
# For simplicity, hash the final GHCID string directly
import hashlib
import uuid
# GHCID UUID v5 Namespace
GHCID_NAMESPACE = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8')
# Generate UUID v5 (SHA-1)
ghcid_uuid = uuid.uuid5(GHCID_NAMESPACE, final_ghcid)
# Generate UUID v8 (SHA-256)
hash_bytes = hashlib.sha256(final_ghcid.encode('utf-8')).digest()
uuid_bytes = bytearray(hash_bytes[:16])
uuid_bytes[6] = (uuid_bytes[6] & 0x0F) | 0x80 # Version 8
uuid_bytes[8] = (uuid_bytes[8] & 0x3F) | 0x80 # Variant RFC 4122
ghcid_uuid_sha256 = uuid.UUID(bytes=bytes(uuid_bytes))
# Generate numeric (64-bit)
ghcid_numeric = int.from_bytes(hash_bytes[:8], byteorder='big', signed=False)
# Generate record ID (UUID v7 - time-ordered, non-deterministic)
record_id = GHCIDComponents.generate_uuid_v7()
# Create GHCID block for entry
ghcid_block = {
'ghcid_current': final_ghcid,
'ghcid_original': final_ghcid, # Same for first assignment
'ghcid_uuid': str(ghcid_uuid),
'ghcid_uuid_sha256': str(ghcid_uuid_sha256),
'ghcid_numeric': ghcid_numeric,
'record_id': str(record_id),
'generation_timestamp': generation_timestamp,
'ghcid_history': [
{
'ghcid': final_ghcid,
'ghcid_numeric': ghcid_numeric,
'valid_from': generation_timestamp,
'valid_to': None,
'reason': 'Initial GHCID assignment (NDE batch import December 2025)'
+ (' - name suffix added to resolve collision' if ed.get('had_collision') else ''),
}
],
}
# Add collision info if applicable
if ed.get('had_collision'):
ghcid_block['collision_resolved'] = True
ghcid_block['base_ghcid_before_collision'] = ed['base_ghcid']
# Update entry
entry = ed['entry']
entry['ghcid'] = ghcid_block
# Also add to identifiers list
if 'identifiers' not in entry:
entry['identifiers'] = []
# Remove any existing GHCID identifiers
entry['identifiers'] = [
i for i in entry['identifiers']
if i.get('identifier_scheme') not in ['GHCID', 'GHCID_NUMERIC', 'GHCID_UUID', 'GHCID_UUID_SHA256', 'RECORD_ID']
]
# Add new GHCID identifiers
entry['identifiers'].extend([
{
'identifier_scheme': 'GHCID',
'identifier_value': final_ghcid,
},
{
'identifier_scheme': 'GHCID_UUID',
'identifier_value': str(ghcid_uuid),
'identifier_url': f'urn:uuid:{ghcid_uuid}',
},
{
'identifier_scheme': 'GHCID_UUID_SHA256',
'identifier_value': str(ghcid_uuid_sha256),
'identifier_url': f'urn:uuid:{ghcid_uuid_sha256}',
},
{
'identifier_scheme': 'GHCID_NUMERIC',
'identifier_value': str(ghcid_numeric),
},
{
'identifier_scheme': 'RECORD_ID',
'identifier_value': str(record_id),
'identifier_url': f'urn:uuid:{record_id}',
},
])
ed['entry'] = entry
stats['success'] += 1
# Phase 5: Write updated entries
if not dry_run:
print("\nPhase 5: Writing updated entry files...")
for ed in entries_data:
filepath = ed['filepath']
entry = ed['entry']
try:
with open(filepath, 'w', encoding='utf-8') as f:
yaml.dump(entry, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
stats['files_updated'] += 1
except Exception as e:
stats['errors'].append(f"Write error {filepath.name}: {str(e)}")
print(f" Updated {stats['files_updated']} files")
else:
print("\nPhase 5: DRY RUN - no files written")
# Phase 6: Generate collision report
print("\nPhase 6: Generating collision report...")
if collision_report:
report_path = entries_dir.parent / "ghcid_collision_report.json"
report = {
'generation_timestamp': generation_timestamp,
'total_entries': stats['total'],
'entries_with_ghcid': stats['success'],
'collision_groups': stats['collision_groups'],
'entries_with_collisions': stats['collisions'],
'collision_resolution_strategy': 'first_batch_all_get_name_suffix',
'collisions': collision_report,
}
if not dry_run:
with open(report_path, 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f" Collision report written to: {report_path}")
else:
print(f" Would write collision report to: {report_path}")
return stats
def main():
"""Main execution."""
parser = argparse.ArgumentParser(description="Enrich NDE entries with GHCID identifiers")
parser.add_argument('--dry-run', action='store_true', help="Preview changes without writing")
args = parser.parse_args()
# Paths
project_root = Path(__file__).parent.parent
entries_dir = project_root / "data" / "nde" / "enriched" / "entries"
print("="*70)
print("NDE HERITAGE INSTITUTION GHCID ENRICHMENT")
print("="*70)
print(f"Entries directory: {entries_dir}")
print(f"Dry run: {args.dry_run}")
print()
if not entries_dir.exists():
print(f"ERROR: Entries directory not found: {entries_dir}")
sys.exit(1)
# Process entries
stats = process_entries(entries_dir, dry_run=args.dry_run)
# Print summary
print()
print("="*70)
print("GHCID ENRICHMENT SUMMARY")
print("="*70)
print(f"Total entry files: {stats['total']}")
print(f"Entries with GHCID generated: {stats['success']}")
print(f"Skipped (no city): {stats['skipped_no_location']}")
print(f"Skipped (NOT_CUSTODIAN): {stats['skipped_not_custodian']}")
print(f"Collision groups: {stats['collision_groups']}")
print(f"Entries with collisions: {stats['collisions']}")
print(f"Files updated: {stats['files_updated']}")
if stats['errors']:
print(f"\nErrors ({len(stats['errors'])}):")
for err in stats['errors'][:10]:
print(f" - {err}")
if len(stats['errors']) > 10:
print(f" ... and {len(stats['errors']) - 10} more")
print()
print("="*70)
if args.dry_run:
print("DRY RUN COMPLETE - No files were modified")
else:
print("GHCID ENRICHMENT COMPLETE")
print("="*70)
if __name__ == "__main__":
main()