rdf-construct 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. rdf_construct/__init__.py +1 -1
  2. rdf_construct/cli.py +1794 -0
  3. rdf_construct/describe/__init__.py +93 -0
  4. rdf_construct/describe/analyzer.py +176 -0
  5. rdf_construct/describe/documentation.py +146 -0
  6. rdf_construct/describe/formatters/__init__.py +47 -0
  7. rdf_construct/describe/formatters/json.py +65 -0
  8. rdf_construct/describe/formatters/markdown.py +275 -0
  9. rdf_construct/describe/formatters/text.py +315 -0
  10. rdf_construct/describe/hierarchy.py +232 -0
  11. rdf_construct/describe/imports.py +213 -0
  12. rdf_construct/describe/metadata.py +187 -0
  13. rdf_construct/describe/metrics.py +145 -0
  14. rdf_construct/describe/models.py +552 -0
  15. rdf_construct/describe/namespaces.py +180 -0
  16. rdf_construct/describe/profiles.py +415 -0
  17. rdf_construct/localise/__init__.py +114 -0
  18. rdf_construct/localise/config.py +508 -0
  19. rdf_construct/localise/extractor.py +427 -0
  20. rdf_construct/localise/formatters/__init__.py +36 -0
  21. rdf_construct/localise/formatters/markdown.py +229 -0
  22. rdf_construct/localise/formatters/text.py +224 -0
  23. rdf_construct/localise/merger.py +346 -0
  24. rdf_construct/localise/reporter.py +356 -0
  25. rdf_construct/merge/__init__.py +165 -0
  26. rdf_construct/merge/config.py +354 -0
  27. rdf_construct/merge/conflicts.py +281 -0
  28. rdf_construct/merge/formatters.py +426 -0
  29. rdf_construct/merge/merger.py +425 -0
  30. rdf_construct/merge/migrator.py +339 -0
  31. rdf_construct/merge/rules.py +377 -0
  32. rdf_construct/merge/splitter.py +1102 -0
  33. rdf_construct/refactor/__init__.py +72 -0
  34. rdf_construct/refactor/config.py +362 -0
  35. rdf_construct/refactor/deprecator.py +328 -0
  36. rdf_construct/refactor/formatters/__init__.py +8 -0
  37. rdf_construct/refactor/formatters/text.py +311 -0
  38. rdf_construct/refactor/renamer.py +294 -0
  39. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/METADATA +91 -6
  40. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/RECORD +43 -7
  41. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/WHEEL +0 -0
  42. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/entry_points.txt +0 -0
  43. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,427 @@
1
+ """String extraction from RDF ontologies.
2
+
3
+ Extracts translatable strings (labels, comments, definitions) from ontology
4
+ files and generates translation files in YAML format.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from rdflib import Graph, Literal, URIRef
12
+ from rdflib.namespace import OWL, RDF, RDFS
13
+
14
+ from rdf_construct.localise.config import (
15
+ EntityTranslations,
16
+ ExtractConfig,
17
+ TranslationEntry,
18
+ TranslationFile,
19
+ TranslationFileMetadata,
20
+ TranslationStatus,
21
+ )
22
+
23
+
24
+ # Standard property URIs
25
+ LABEL_PROPERTIES = [
26
+ "http://www.w3.org/2000/01/rdf-schema#label",
27
+ "http://www.w3.org/2004/02/skos/core#prefLabel",
28
+ "http://www.w3.org/2004/02/skos/core#altLabel",
29
+ ]
30
+
31
+ COMMENT_PROPERTIES = [
32
+ "http://www.w3.org/2000/01/rdf-schema#comment",
33
+ "http://www.w3.org/2004/02/skos/core#definition",
34
+ "http://www.w3.org/2004/02/skos/core#example",
35
+ "http://www.w3.org/2004/02/skos/core#note",
36
+ "http://www.w3.org/2004/02/skos/core#scopeNote",
37
+ ]
38
+
39
+ DEFAULT_PROPERTIES = LABEL_PROPERTIES[:2] + COMMENT_PROPERTIES[:2]
40
+
41
+
42
+ @dataclass
43
+ class ExtractionResult:
44
+ """Result of a string extraction operation.
45
+
46
+ Attributes:
47
+ success: Whether extraction succeeded.
48
+ translation_file: Generated translation file.
49
+ total_entities: Number of entities processed.
50
+ total_strings: Number of strings extracted.
51
+ skipped_entities: Number of entities skipped.
52
+ error: Error message if failed.
53
+ """
54
+
55
+ success: bool
56
+ translation_file: TranslationFile | None = None
57
+ total_entities: int = 0
58
+ total_strings: int = 0
59
+ skipped_entities: int = 0
60
+ error: str | None = None
61
+
62
+
63
+ class StringExtractor:
64
+ """Extracts translatable strings from RDF ontologies.
65
+
66
+ The extractor identifies entities (classes, properties, individuals) and
67
+ extracts text values for configured properties (rdfs:label, rdfs:comment, etc.)
68
+ in the source language. The output is a translation file with empty
69
+ translation fields ready for translators.
70
+ """
71
+
72
+ def __init__(self, config: ExtractConfig | None = None):
73
+ """Initialise the extractor.
74
+
75
+ Args:
76
+ config: Extraction configuration. Uses defaults if not provided.
77
+ """
78
+ self.config = config or ExtractConfig()
79
+
80
+ def extract(
81
+ self,
82
+ graph: Graph,
83
+ source_file: Path | str,
84
+ target_language: str | None = None,
85
+ ) -> ExtractionResult:
86
+ """Extract translatable strings from an RDF graph.
87
+
88
+ Args:
89
+ graph: RDF graph to extract from.
90
+ source_file: Path to source file (for metadata).
91
+ target_language: Override target language from config.
92
+
93
+ Returns:
94
+ ExtractionResult with translation file.
95
+ """
96
+ target_lang = target_language or self.config.target_language
97
+ if not target_lang:
98
+ return ExtractionResult(
99
+ success=False,
100
+ error="No target language specified",
101
+ )
102
+
103
+ try:
104
+ # Get all entities from the graph
105
+ entities = self._collect_entities(graph)
106
+
107
+ # Extract translations for each entity
108
+ entity_translations: list[EntityTranslations] = []
109
+ total_strings = 0
110
+ skipped = 0
111
+
112
+ for entity_uri, entity_type in entities:
113
+ # Check for deprecation
114
+ if not self.config.include_deprecated and self._is_deprecated(
115
+ graph, entity_uri
116
+ ):
117
+ skipped += 1
118
+ continue
119
+
120
+ # Extract labels for this entity
121
+ labels = self._extract_entity_labels(
122
+ graph,
123
+ entity_uri,
124
+ target_lang,
125
+ )
126
+
127
+ if not labels:
128
+ if self.config.include_unlabelled:
129
+ # Include entity with empty labels
130
+ pass
131
+ else:
132
+ skipped += 1
133
+ continue
134
+
135
+ if labels:
136
+ entity_translations.append(
137
+ EntityTranslations(
138
+ uri=str(entity_uri),
139
+ entity_type=entity_type,
140
+ labels=labels,
141
+ )
142
+ )
143
+ total_strings += len(labels)
144
+
145
+ # Build translation file
146
+ metadata = TranslationFileMetadata(
147
+ source_file=str(source_file),
148
+ source_language=self.config.source_language,
149
+ target_language=target_lang,
150
+ generated=datetime.now(),
151
+ properties=[self._shorten_property(p) for p in self.config.properties],
152
+ )
153
+
154
+ translation_file = TranslationFile(
155
+ metadata=metadata,
156
+ entities=entity_translations,
157
+ )
158
+
159
+ return ExtractionResult(
160
+ success=True,
161
+ translation_file=translation_file,
162
+ total_entities=len(entity_translations),
163
+ total_strings=total_strings,
164
+ skipped_entities=skipped,
165
+ )
166
+
167
+ except Exception as e:
168
+ return ExtractionResult(
169
+ success=False,
170
+ error=str(e),
171
+ )
172
+
173
+ def _collect_entities(self, graph: Graph) -> list[tuple[URIRef, str]]:
174
+ """Collect all entities from the graph with their types.
175
+
176
+ Args:
177
+ graph: RDF graph.
178
+
179
+ Returns:
180
+ List of (URI, type_string) tuples.
181
+ """
182
+ entities: list[tuple[URIRef, str]] = []
183
+ seen: set[URIRef] = set()
184
+
185
+ # Classes
186
+ for cls_type in [OWL.Class, RDFS.Class]:
187
+ for s in graph.subjects(RDF.type, cls_type):
188
+ if isinstance(s, URIRef) and s not in seen:
189
+ seen.add(s)
190
+ entities.append((s, "owl:Class"))
191
+
192
+ # Object Properties
193
+ for s in graph.subjects(RDF.type, OWL.ObjectProperty):
194
+ if isinstance(s, URIRef) and s not in seen:
195
+ seen.add(s)
196
+ entities.append((s, "owl:ObjectProperty"))
197
+
198
+ # Datatype Properties
199
+ for s in graph.subjects(RDF.type, OWL.DatatypeProperty):
200
+ if isinstance(s, URIRef) and s not in seen:
201
+ seen.add(s)
202
+ entities.append((s, "owl:DatatypeProperty"))
203
+
204
+ # Annotation Properties
205
+ for s in graph.subjects(RDF.type, OWL.AnnotationProperty):
206
+ if isinstance(s, URIRef) and s not in seen:
207
+ seen.add(s)
208
+ entities.append((s, "owl:AnnotationProperty"))
209
+
210
+ # RDF Properties
211
+ for s in graph.subjects(RDF.type, RDF.Property):
212
+ if isinstance(s, URIRef) and s not in seen:
213
+ seen.add(s)
214
+ entities.append((s, "rdf:Property"))
215
+
216
+ # Named Individuals
217
+ for s in graph.subjects(RDF.type, OWL.NamedIndividual):
218
+ if isinstance(s, URIRef) and s not in seen:
219
+ seen.add(s)
220
+ entities.append((s, "owl:NamedIndividual"))
221
+
222
+ # Sort by URI for consistent output
223
+ entities.sort(key=lambda x: str(x[0]))
224
+
225
+ return entities
226
+
227
+ def _extract_entity_labels(
228
+ self,
229
+ graph: Graph,
230
+ entity: URIRef,
231
+ target_lang: str,
232
+ ) -> list[TranslationEntry]:
233
+ """Extract label properties for a single entity.
234
+
235
+ Args:
236
+ graph: RDF graph.
237
+ entity: Entity URI.
238
+ target_lang: Target language code.
239
+
240
+ Returns:
241
+ List of TranslationEntry objects.
242
+ """
243
+ labels: list[TranslationEntry] = []
244
+ source_lang = self.config.source_language
245
+
246
+ for prop_uri_str in self.config.properties:
247
+ prop_uri = URIRef(self._expand_property(prop_uri_str))
248
+
249
+ # Find source language literals
250
+ source_literals = self._get_language_literals(
251
+ graph, entity, prop_uri, source_lang
252
+ )
253
+
254
+ if not source_literals:
255
+ continue
256
+
257
+ # Check for existing translation if missing_only mode
258
+ if self.config.missing_only:
259
+ existing = self._get_language_literals(
260
+ graph, entity, prop_uri, target_lang
261
+ )
262
+ if existing:
263
+ continue
264
+
265
+ for source_text in source_literals:
266
+ labels.append(
267
+ TranslationEntry(
268
+ property=self._shorten_property(str(prop_uri)),
269
+ source_text=source_text,
270
+ translation="",
271
+ status=TranslationStatus.PENDING,
272
+ )
273
+ )
274
+
275
+ return labels
276
+
277
+ def _get_language_literals(
278
+ self,
279
+ graph: Graph,
280
+ subject: URIRef,
281
+ predicate: URIRef,
282
+ language: str,
283
+ ) -> list[str]:
284
+ """Get literal values for a specific language.
285
+
286
+ Args:
287
+ graph: RDF graph.
288
+ subject: Subject URI.
289
+ predicate: Predicate URI.
290
+ language: Language code.
291
+
292
+ Returns:
293
+ List of literal string values.
294
+ """
295
+ results: list[str] = []
296
+
297
+ for obj in graph.objects(subject, predicate):
298
+ if isinstance(obj, Literal):
299
+ # Match language exactly or match untagged literals for source
300
+ obj_lang = obj.language
301
+ if obj_lang == language:
302
+ results.append(str(obj))
303
+ elif obj_lang is None and language == self.config.source_language:
304
+ # Treat untagged literals as source language
305
+ results.append(str(obj))
306
+
307
+ return results
308
+
309
+ def _is_deprecated(self, graph: Graph, entity: URIRef) -> bool:
310
+ """Check if an entity is deprecated.
311
+
312
+ Args:
313
+ graph: RDF graph.
314
+ entity: Entity URI.
315
+
316
+ Returns:
317
+ True if entity is deprecated.
318
+ """
319
+ # Check owl:deprecated
320
+ for obj in graph.objects(entity, OWL.deprecated):
321
+ if isinstance(obj, Literal) and obj.toPython() is True:
322
+ return True
323
+
324
+ # Check owl:DeprecatedClass / owl:DeprecatedProperty
325
+ deprecated_types = [OWL.DeprecatedClass, OWL.DeprecatedProperty]
326
+ for dtype in deprecated_types:
327
+ if (entity, RDF.type, dtype) in graph:
328
+ return True
329
+
330
+ return False
331
+
332
+ def _expand_property(self, prop: str) -> str:
333
+ """Expand a CURIE to full URI.
334
+
335
+ Args:
336
+ prop: Property string (CURIE or full URI).
337
+
338
+ Returns:
339
+ Full URI string.
340
+ """
341
+ prefixes = {
342
+ "rdfs:": "http://www.w3.org/2000/01/rdf-schema#",
343
+ "skos:": "http://www.w3.org/2004/02/skos/core#",
344
+ "owl:": "http://www.w3.org/2002/07/owl#",
345
+ "rdf:": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
346
+ "dc:": "http://purl.org/dc/elements/1.1/",
347
+ "dcterms:": "http://purl.org/dc/terms/",
348
+ }
349
+
350
+ for prefix, namespace in prefixes.items():
351
+ if prop.startswith(prefix):
352
+ return namespace + prop[len(prefix) :]
353
+
354
+ return prop
355
+
356
+ def _shorten_property(self, prop: str) -> str:
357
+ """Shorten a full URI to CURIE if possible.
358
+
359
+ Args:
360
+ prop: Full property URI.
361
+
362
+ Returns:
363
+ CURIE or original URI.
364
+ """
365
+ namespaces = {
366
+ "http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
367
+ "http://www.w3.org/2004/02/skos/core#": "skos:",
368
+ "http://www.w3.org/2002/07/owl#": "owl:",
369
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
370
+ "http://purl.org/dc/elements/1.1/": "dc:",
371
+ "http://purl.org/dc/terms/": "dcterms:",
372
+ }
373
+
374
+ for namespace, prefix in namespaces.items():
375
+ if prop.startswith(namespace):
376
+ return prefix + prop[len(namespace) :]
377
+
378
+ return prop
379
+
380
+
381
+ def extract_strings(
382
+ source: Path,
383
+ target_language: str,
384
+ output: Path | None = None,
385
+ source_language: str = "en",
386
+ properties: list[str] | None = None,
387
+ include_deprecated: bool = False,
388
+ missing_only: bool = False,
389
+ ) -> ExtractionResult:
390
+ """Extract translatable strings from an ontology file.
391
+
392
+ Convenience function for simple extraction.
393
+
394
+ Args:
395
+ source: Source ontology file.
396
+ target_language: Target language code.
397
+ output: Output file path. Auto-generated if not provided.
398
+ source_language: Source language code.
399
+ properties: Properties to extract. Uses defaults if not provided.
400
+ include_deprecated: Include deprecated entities.
401
+ missing_only: Only extract missing translations.
402
+
403
+ Returns:
404
+ ExtractionResult with translation file.
405
+ """
406
+ # Load graph
407
+ graph = Graph()
408
+ graph.parse(source)
409
+
410
+ # Build config
411
+ config = ExtractConfig(
412
+ source_language=source_language,
413
+ target_language=target_language,
414
+ properties=properties or list(DEFAULT_PROPERTIES),
415
+ include_deprecated=include_deprecated,
416
+ missing_only=missing_only,
417
+ )
418
+
419
+ # Extract
420
+ extractor = StringExtractor(config)
421
+ result = extractor.extract(graph, source, target_language)
422
+
423
+ # Save if requested
424
+ if result.success and output and result.translation_file:
425
+ result.translation_file.save(output)
426
+
427
+ return result
@@ -0,0 +1,36 @@
1
+ """Formatters for localise command output.
2
+
3
+ Provides output formatting for:
4
+ - Console text output
5
+ - Markdown reports
6
+ """
7
+
8
+ from rdf_construct.localise.formatters.text import TextFormatter
9
+ from rdf_construct.localise.formatters.markdown import MarkdownFormatter
10
+
11
+ __all__ = [
12
+ "TextFormatter",
13
+ "MarkdownFormatter",
14
+ "get_formatter",
15
+ ]
16
+
17
+
18
+ def get_formatter(format_name: str, use_colour: bool = True) -> TextFormatter | MarkdownFormatter:
19
+ """Get a formatter by name.
20
+
21
+ Args:
22
+ format_name: Formatter name ("text" or "markdown").
23
+ use_colour: Whether to use colour output (text only).
24
+
25
+ Returns:
26
+ Formatter instance.
27
+
28
+ Raises:
29
+ ValueError: If format name is unknown.
30
+ """
31
+ if format_name == "text":
32
+ return TextFormatter(use_colour=use_colour)
33
+ elif format_name in ("markdown", "md"):
34
+ return MarkdownFormatter()
35
+ else:
36
+ raise ValueError(f"Unknown format: {format_name}")
@@ -0,0 +1,229 @@
1
+ """Markdown formatter for coverage reports.
2
+
3
+ Generates markdown-formatted coverage reports suitable for documentation
4
+ or inclusion in PRs/issues.
5
+ """
6
+
7
+ from datetime import datetime
8
+
9
+ from rdf_construct.localise.extractor import ExtractionResult
10
+ from rdf_construct.localise.merger import MergeResult
11
+ from rdf_construct.localise.reporter import CoverageReport
12
+
13
+
14
+ class MarkdownFormatter:
15
+ """Formats localise results as Markdown."""
16
+
17
+ def format_extraction_result(self, result: ExtractionResult) -> str:
18
+ """Format extraction result as Markdown.
19
+
20
+ Args:
21
+ result: Extraction result.
22
+
23
+ Returns:
24
+ Markdown string.
25
+ """
26
+ lines: list[str] = []
27
+
28
+ lines.append("# Extraction Result")
29
+ lines.append("")
30
+
31
+ if result.success:
32
+ lines.append("**Status:** ✅ Success")
33
+ lines.append("")
34
+ lines.append("| Metric | Value |")
35
+ lines.append("|--------|-------|")
36
+ lines.append(f"| Entities | {result.total_entities} |")
37
+ lines.append(f"| Strings | {result.total_strings} |")
38
+ lines.append(f"| Skipped | {result.skipped_entities} |")
39
+
40
+ if result.translation_file:
41
+ tf = result.translation_file
42
+ lines.append("")
43
+ lines.append("## Metadata")
44
+ lines.append("")
45
+ lines.append(f"- **Source file:** `{tf.metadata.source_file}`")
46
+ lines.append(f"- **Source language:** {tf.metadata.source_language}")
47
+ lines.append(f"- **Target language:** {tf.metadata.target_language}")
48
+ else:
49
+ lines.append("**Status:** ❌ Failed")
50
+ lines.append("")
51
+ lines.append(f"**Error:** {result.error}")
52
+
53
+ return "\n".join(lines)
54
+
55
+ def format_merge_result(self, result: MergeResult) -> str:
56
+ """Format merge result as Markdown.
57
+
58
+ Args:
59
+ result: Merge result.
60
+
61
+ Returns:
62
+ Markdown string.
63
+ """
64
+ lines: list[str] = []
65
+
66
+ lines.append("# Merge Result")
67
+ lines.append("")
68
+
69
+ if result.success:
70
+ lines.append("**Status:** ✅ Success")
71
+ lines.append("")
72
+
73
+ stats = result.stats
74
+ lines.append("| Metric | Count |")
75
+ lines.append("|--------|-------|")
76
+ lines.append(f"| Added | {stats.added} |")
77
+ lines.append(f"| Updated | {stats.updated} |")
78
+ lines.append(f"| Skipped (status) | {stats.skipped_status} |")
79
+ lines.append(f"| Skipped (existing) | {stats.skipped_existing} |")
80
+ lines.append(f"| Errors | {stats.errors} |")
81
+
82
+ if result.warnings:
83
+ lines.append("")
84
+ lines.append("## Warnings")
85
+ lines.append("")
86
+ for warning in result.warnings:
87
+ lines.append(f"- {warning}")
88
+ else:
89
+ lines.append("**Status:** ❌ Failed")
90
+ lines.append("")
91
+ lines.append(f"**Error:** {result.error}")
92
+
93
+ return "\n".join(lines)
94
+
95
+ def format_coverage_report(
96
+ self,
97
+ report: CoverageReport,
98
+ verbose: bool = False,
99
+ ) -> str:
100
+ """Format coverage report as Markdown.
101
+
102
+ Args:
103
+ report: Coverage report.
104
+ verbose: Include detailed missing entity list.
105
+
106
+ Returns:
107
+ Markdown string.
108
+ """
109
+ lines: list[str] = []
110
+
111
+ # Header
112
+ lines.append("# Translation Coverage Report")
113
+ lines.append("")
114
+ lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
115
+ lines.append("")
116
+
117
+ # Summary
118
+ lines.append("## Summary")
119
+ lines.append("")
120
+ lines.append(f"- **Source file:** `{report.source_file}`")
121
+ lines.append(f"- **Source language:** {report.source_language}")
122
+ lines.append(f"- **Total entities:** {report.total_entities}")
123
+ lines.append(f"- **Properties checked:** {', '.join(report.properties)}")
124
+ lines.append("")
125
+
126
+ # Coverage table
127
+ lines.append("## Coverage by Language")
128
+ lines.append("")
129
+
130
+ # Build header
131
+ header = ["Language"]
132
+ header.extend(report.properties)
133
+ header.append("Overall")
134
+ header.append("Status")
135
+ lines.append("| " + " | ".join(header) + " |")
136
+ lines.append("| " + " | ".join(["---"] * len(header)) + " |")
137
+
138
+ # Data rows
139
+ for lang, coverage in report.languages.items():
140
+ row = []
141
+
142
+ # Language
143
+ if coverage.is_source:
144
+ row.append(f"**{lang}** (base)")
145
+ else:
146
+ row.append(lang)
147
+
148
+ # Property coverages
149
+ for prop in report.properties:
150
+ prop_cov = coverage.by_property.get(prop)
151
+ if prop_cov:
152
+ pct = f"{prop_cov.coverage:.0f}%"
153
+ else:
154
+ pct = "-"
155
+ row.append(pct)
156
+
157
+ # Overall
158
+ row.append(f"**{coverage.coverage:.0f}%**")
159
+
160
+ # Status
161
+ if coverage.coverage == 100:
162
+ row.append("✅ Complete")
163
+ elif coverage.coverage >= 75:
164
+ row.append(f"⚠️ {coverage.pending} pending")
165
+ elif coverage.coverage > 0:
166
+ row.append(f"❌ {coverage.pending} pending")
167
+ else:
168
+ row.append("❌ Not started")
169
+
170
+ lines.append("| " + " | ".join(row) + " |")
171
+
172
+ # Missing translations section
173
+ if verbose:
174
+ has_missing = False
175
+ for lang, coverage in report.languages.items():
176
+ if coverage.missing_entities and not coverage.is_source:
177
+ if not has_missing:
178
+ lines.append("")
179
+ lines.append("## Missing Translations")
180
+ has_missing = True
181
+
182
+ lines.append("")
183
+ lines.append(f"### {lang.upper()}")
184
+ lines.append("")
185
+
186
+ # Group by entity type based on URI pattern
187
+ lines.append("<details>")
188
+ lines.append(f"<summary>{len(coverage.missing_entities)} entities missing translations</summary>")
189
+ lines.append("")
190
+ for uri in coverage.missing_entities:
191
+ short_uri = self._shorten_uri(uri)
192
+ lines.append(f"- `{short_uri}`")
193
+ lines.append("")
194
+ lines.append("</details>")
195
+
196
+ # Footer
197
+ lines.append("")
198
+ lines.append("---")
199
+ lines.append("*Generated by rdf-construct localise*")
200
+
201
+ return "\n".join(lines)
202
+
203
+ def _shorten_uri(self, uri: str) -> str:
204
+ """Shorten a URI for display.
205
+
206
+ Args:
207
+ uri: Full URI.
208
+
209
+ Returns:
210
+ Shortened version.
211
+ """
212
+ prefixes = {
213
+ "http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
214
+ "http://www.w3.org/2004/02/skos/core#": "skos:",
215
+ "http://www.w3.org/2002/07/owl#": "owl:",
216
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
217
+ }
218
+
219
+ for namespace, prefix in prefixes.items():
220
+ if uri.startswith(namespace):
221
+ return prefix + uri[len(namespace) :]
222
+
223
+ # If no known prefix, just show local name
224
+ if "#" in uri:
225
+ return uri.split("#")[-1]
226
+ elif "/" in uri:
227
+ return uri.split("/")[-1]
228
+
229
+ return uri