1use crate::align::print_transposition as print_translation;
2use serde::Deserialize;
3use stam::*;
4use std::borrow::Cow;
5use std::collections::HashMap;
6use toml;
7
8pub fn translate<'store>(
9 store: &'store mut AnnotationStore,
10 mut translation_queries: Vec<Query<'store>>,
11 queries: Vec<Query<'store>>,
12 use_translation_var: Option<&str>,
13 use_var: Option<&str>,
14 id_prefix: Option<String>,
15 idstrategy: IdStrategy,
16 ignore_errors: bool,
17 verbose: bool,
18 config: TranslateConfig,
19) -> Result<Vec<AnnotationHandle>, StamError> {
20 let mut builders = Vec::new();
21 while translation_queries.len() < queries.len() {
22 let query = translation_queries
23 .get(translation_queries.len() - 1)
24 .expect("there must be translation queries");
25 translation_queries.push(query.clone());
26 }
27 for (translation_query, query) in translation_queries.into_iter().zip(queries.into_iter()) {
28 let iter = store.query(translation_query)?;
29 let mut translation: Option<ResultItem<Annotation>> = None;
30 let translationdata = store
31 .find_data(
32 "https://w3id.org/stam/extensions/stam-translate/",
33 "Translation",
34 DataOperator::Null,
35 )
36 .next()
37 .ok_or_else(|| {
38 StamError::OtherError(
39 "No translations at all were found in the annotation store (the STAM translate vocabulary is not present in the store)",
40 )
41 })?;
42 for resultrow in iter {
43 if let Ok(QueryResultItem::Annotation(annotation)) =
44 resultrow.get_by_name_or_last(use_translation_var)
45 {
46 if !annotation.has_data(&translationdata) {
47 return Err(StamError::OtherError(
48 "The retrieved annotation is not explicitly marked as a translation, refusing to use",
49 ));
50 }
51 translation = Some(annotation.clone());
52 break;
53 }
54 }
55 if let Some(translation) = translation {
56 let iter = store.query(query)?;
57 let translationpivot: TranslationPivot<'_> = (&translation).try_into()?;
58 for resultrow in iter {
59 if let Ok(QueryResultItem::Annotation(annotation)) =
60 resultrow.get_by_name_or_last(use_var)
61 {
62 let mut config = config.clone();
63 if let Some(id) = annotation.id() {
64 let randomid = generate_id("", "");
65 config.translation_id = if let Some(id_prefix) = &id_prefix {
66 Some(format!("{}{}-translation-{}", id_prefix, id, randomid))
67 } else {
68 Some(format!("{}-translation-{}", id, randomid))
69 };
70 config.resegmentation_id = if let Some(id_prefix) = &id_prefix {
71 Some(format!("{}{}-resegmentation-{}", id_prefix, id, randomid))
72 } else {
73 Some(format!("{}-resegmentation-{}", id, randomid))
74 };
75 config.source_side_id = Some(id.to_string());
76 config.existing_source_side = true;
77 config.target_side_ids = vec![if let Some(id_prefix) = &id_prefix {
78 format!("{}{}", id_prefix, regenerate_id(id, &idstrategy))
79 } else {
80 regenerate_id(id, &idstrategy)
81 }];
82 } else {
83 config.existing_source_side = false;
84 }
85 match annotation.translate(&translationpivot, config) {
86 Ok(results) => builders.extend(results),
87 Err(StamError::NoText(e)) => {
88 eprintln!(
89 "WARNING: Skipping translation of annotation {} that references no text: {}",
90 annotation.id().unwrap_or("(no id)"),
91 e
92 );
93 }
94 Err(err) => {
95 eprintln!(
96 "WARNING: Failed to translate annotation {}: {}",
97 annotation.id().unwrap_or("(no id)"),
98 err
99 );
100 if !ignore_errors {
101 return Err(StamError::OtherError(
102 "Failed to translate annotation",
103 ));
104 }
105 }
106 }
107 } else {
108 return Err(StamError::OtherError(
109 "Query should return instances of ANNOTATION to translate, got something else instead",
110 ));
111 }
112 }
113 } else {
114 return Err(StamError::OtherError(
115 "Translation queries should return an ANNOTATION that is a translation, none found",
116 ));
117 }
118 }
119 let mut annotations = Vec::with_capacity(builders.len());
120 for builder in builders {
121 let annotation_handle = if !config.modify_existing {
122 let annotation_handle = store.annotate(builder)?;
124 annotations.push(annotation_handle);
125 annotation_handle
126 } else {
127 store.reannotate(builder, ReannotateMode::default())?
129 };
130 if verbose {
131 let annotation = store
132 .annotation(annotation_handle)
133 .expect("annotation was just added");
134 let translationdata = store
135 .find_data(
136 "https://w3id.org/stam/extensions/stam-translate/",
137 "Translation",
138 DataOperator::Null,
139 )
140 .next()
141 .ok_or_else(|| {
142 StamError::OtherError(
143 "No translations at all were found in the annotation store (the STAM translate vocabulary is not present in the store)",
144 )
145 })?;
146 if annotation.has_data(&translationdata) {
147 print_translation(&annotation);
148 } else if config.modify_existing {
149 eprintln!(
150 "# updated annotation {}",
151 annotation.id().expect("annotation must have ID")
152 );
153 } else {
154 eprintln!(
155 "# added annotation {}",
156 annotation.id().expect("annotation must have ID")
157 );
158 }
159 }
160 }
161 if verbose {
162 if !config.modify_existing {
163 eprintln!("{} annotations(s) created", annotations.len());
164 } else {
165 eprintln!("{} annotations(s) updated", annotations.len());
166 }
167 }
168 Ok(annotations)
169}
170
171#[derive(Clone, Default, Deserialize, Debug)]
172pub struct TranslateTextRule {
173 source: Option<String>,
174 target: String,
175 left: Option<String>,
176 right: Option<String>,
177
178 #[serde(default = "f_true")]
179 case_sensitive: bool,
180
181 #[serde(default)]
182 invert_context_match: bool,
183
184 #[serde(default)]
185 constraints: Vec<TranslateTextConstraint>,
186
187 #[serde(skip)]
188 source_regex: Option<Regex>,
189 #[serde(skip)]
190 left_regex: Option<Regex>,
191 #[serde(skip)]
192 right_regex: Option<Regex>,
193}
194
195fn f_true() -> bool {
197 true
198}
199
200#[derive(Clone, Default, Deserialize, Debug)]
201pub struct TranslateTextConstraint {
202 query: String,
203
204 #[serde(default)]
205 test: Option<String>,
206
207 #[serde(default)]
208 invert: bool,
209}
210
211pub struct MatchedRule<'a> {
212 source: &'a str,
213 target: Cow<'a, str>,
214}
215
216impl TranslateTextRule {
217 pub fn test<'a>(&'a self, text: &'a str, bytecursor: usize) -> Option<MatchedRule<'a>> {
219 if let Some(source_regex) = self.source_regex.as_ref() {
220 if let Some(m) = source_regex.find(&text[bytecursor..]) {
222 if self.test_context(text, bytecursor, m.len()) {
223 return Some(MatchedRule {
224 target: self.get_target(m.as_str()),
225 source: m.as_str(),
226 });
227 }
228 }
229 } else if let Some(source) = self.source.as_ref() {
230 if bytecursor + source.len() <= text.len() {
232 if let Some(candidate) = text.get(bytecursor..bytecursor + source.len()) {
233 if ((self.case_sensitive && candidate == *source)
234 || (!self.case_sensitive && candidate.to_lowercase() == *source))
235 && self.test_context(text, bytecursor, source.len())
236 {
237 return Some(MatchedRule {
238 target: self.get_target(source.as_str()),
239 source: source.as_str().into(),
240 });
241 }
242 }
243 }
244 }
245 None
246 }
247
248 fn test_context(&self, text: &str, bytecursor: usize, matchbytelen: usize) -> bool {
250 if let Some(left_regex) = self.left_regex.as_ref() {
251 let leftcontext = &text[..bytecursor];
253 if !left_regex.is_match(leftcontext) {
254 if self.invert_context_match {
255 return true;
256 } else {
257 return false;
258 }
259 }
260 } else if let Some(left_pattern) = self.left.as_ref() {
261 let leftcontext = &text[..bytecursor];
263 if (self.case_sensitive && !leftcontext.ends_with(left_pattern))
264 || (!self.case_sensitive
265 && leftcontext[std::cmp::min(0, bytecursor - left_pattern.len())..]
266 .to_lowercase()
267 != left_pattern.to_lowercase())
268 {
269 if self.invert_context_match {
270 return true;
271 } else {
272 return false;
273 }
274 }
275 }
276 if let Some(right_regex) = self.right_regex.as_ref() {
277 let rightcontext = &text[bytecursor + matchbytelen..];
279 if !right_regex.is_match(rightcontext) {
280 if self.invert_context_match {
281 return true;
282 } else {
283 return false;
284 }
285 }
286 } else if let Some(right_pattern) = self.right.as_ref() {
287 let rightcontext = &text[bytecursor + matchbytelen..];
289 if (self.case_sensitive && !rightcontext.starts_with(right_pattern))
290 || (!self.case_sensitive
291 && rightcontext[..std::cmp::min(rightcontext.len(), right_pattern.len())]
292 .to_lowercase()
293 != right_pattern.to_lowercase())
294 {
295 if self.invert_context_match {
296 return true;
297 } else {
298 return false;
299 }
300 }
301 }
302 if self.invert_context_match {
303 return false;
304 } else {
305 return true;
306 }
307 }
308
309 fn get_target<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
310 match self.target.as_str() {
311 "$UPPER" => source.to_uppercase().into(),
312 "$LOWER" => source.to_lowercase().into(),
313 "$REVERSED" => Cow::Owned(source.chars().rev().collect::<String>()),
314 _ => Cow::Borrowed(self.target.as_str()),
315 }
316 }
317}
318
319#[derive(Clone, Default, Deserialize, Debug)]
320pub struct TranslateTextConfig {
321 rules: Vec<TranslateTextRule>,
322
323 #[serde(default)]
325 id_suffix: Option<String>,
326
327 #[serde(default)]
329 discard_unmatched: bool,
330
331 #[serde(default)]
333 no_annotations: bool,
334
335 #[serde(default)]
337 force_when_unchanged: bool,
338
339 #[serde(default)]
340 debug: bool,
341}
342
343impl TranslateTextConfig {
344 pub fn from_toml_str(tomlstr: &str, debug: bool) -> Result<Self, String> {
346 let mut config: Self = toml::from_str(tomlstr).map_err(|e| format!("{}", e))?;
347 config.debug = debug;
348 config.compile_regexps()?;
349 Ok(config)
350 }
351
352 pub fn with_id_suffix(mut self, suffix: impl Into<String>) -> Self {
354 self.id_suffix = Some(suffix.into());
355 self
356 }
357
358 pub fn with_force_when_unchanged(mut self) -> Self {
360 self.force_when_unchanged = true;
361 self
362 }
363
364 pub fn with_debug(mut self, value: bool) -> Self {
366 self.debug = value;
367 self
368 }
369
370 fn compile_regexps<'a>(&'a mut self) -> Result<(), String> {
371 for rule in self.rules.iter_mut() {
372 if let Some(v) = rule.source.as_ref() {
373 if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
374 let regex = format!("^{}", &v[1..v.len() - 1]);
375 rule.source_regex = Some(
376 RegexBuilder::new(®ex)
377 .case_insensitive(!rule.case_sensitive)
378 .build()
379 .map_err(|e| {
380 format!("Invalid regular expression for source: {}: {}", regex, e)
381 })?,
382 );
383 if self.debug {
384 eprintln!(
385 "[stam translatetext] compiled source regex {:?}",
386 rule.source_regex
387 )
388 }
389 }
390 }
391 if let Some(v) = rule.left.as_ref() {
392 if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
393 let regex = format!(".*{}$", &v[1..v.len() - 1]);
394 rule.left_regex = Some(
395 RegexBuilder::new(®ex)
396 .case_insensitive(!rule.case_sensitive)
397 .build()
398 .map_err(|e| {
399 format!(
400 "Invalid regular expression for left context: {}: {}",
401 regex, e
402 )
403 })?,
404 );
405 if self.debug {
406 eprintln!(
407 "[stam translatetext] compiled left context regex {:?}",
408 rule.left_regex
409 )
410 }
411 }
412 }
413 if let Some(v) = rule.right.as_ref() {
414 if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
415 let regex = format!("^{}.*", &v[1..v.len() - 1]);
416 rule.right_regex = Some(
417 RegexBuilder::new(®ex)
418 .case_insensitive(!rule.case_sensitive)
419 .build()
420 .map_err(|e| {
421 format!(
422 "Invalid regular expression for right context: {}: {}",
423 regex, e
424 )
425 })?,
426 );
427 if self.debug {
428 eprintln!(
429 "[stam translatetext] compiled right context regex {:?}",
430 rule.right_regex
431 )
432 }
433 }
434 }
435 if rule.source.is_none() {
436 return Err("Translation rules must have both a source".into());
437 }
438 }
439 if self.debug {
440 eprintln!("[stam translatetext] {} rules read", self.rules.len())
441 }
442 Ok(())
443 }
444
445 pub fn compile_queries<'a>(&'a self) -> Result<HashMap<String, Query<'a>>, String> {
446 let mut compiled_queries = HashMap::new();
447 for rule in self.rules.iter() {
448 for constraint in rule.constraints.iter() {
449 if !compiled_queries.contains_key(constraint.query.as_str()) {
450 compiled_queries.insert(
451 constraint.query.clone(),
452 stam::Query::parse(constraint.query.as_str())
453 .map_err(|err| format!("{}", err))?
454 .0,
455 );
456 }
457 }
458 }
459 Ok(compiled_queries)
460 }
461}
462
463pub fn translate_text<'store>(
465 store: &'store AnnotationStore,
466 queries: Vec<Query<'store>>,
467 usevar: Option<&'store str>,
468 config: &TranslateTextConfig,
469) -> Result<(Vec<TextResourceBuilder>, Vec<AnnotationBuilder<'static>>), String> {
470 let mut annotations = Vec::new();
471 let mut resourcebuilders = Vec::new();
472 let constraint_queries = config.compile_queries()?;
473
474 let mut seqnr = 0;
475 for query in queries.into_iter() {
476 let iter = store.query(query).map_err(|e| format!("{}", e))?;
477 for resultrow in iter {
478 if let Ok(result) = resultrow.get_by_name_or_last(usevar) {
479 match result {
480 QueryResultItem::TextResource(resource) => {
481 let resource_id = resource.id().expect("resource must have ID");
482 let new_resource_id = format!(
483 "{}.{}{}",
484 if resource_id.ends_with(".txt") {
485 &resource_id[..resource_id.len() - 4]
486 } else if resource_id.ends_with(".md") {
487 &resource_id[..resource_id.len() - 3]
488 } else {
489 resource_id
490 },
491 config
492 .id_suffix
493 .as_ref()
494 .map(|s| s.as_str())
495 .unwrap_or("translation"),
496 if resource_id.ends_with(".txt") {
497 ".txt"
498 } else if resource_id.ends_with(".md") {
499 ".md"
500 } else {
501 ""
502 }
503 );
504 let new_filename = if let Some(filename) = resource.as_ref().filename() {
505 Some(format!(
506 "{}.{}.txt",
507 if filename.ends_with(".txt") {
508 &filename[..filename.len() - 4]
509 } else if filename.ends_with(".md") {
510 &filename[..filename.len() - 3]
511 } else {
512 filename
513 },
514 config
515 .id_suffix
516 .as_ref()
517 .map(|s| s.as_str())
518 .unwrap_or("translation")
519 ))
520 } else {
521 None
522 };
523 translate_text_helper(
524 config,
525 store,
526 resource.text(),
527 resource,
528 0,
529 new_resource_id,
530 new_filename,
531 &mut resourcebuilders,
532 &mut annotations,
533 &constraint_queries,
534 )?;
535 }
536 QueryResultItem::TextSelection(textselection) => {
537 seqnr += 1;
538 let resource = textselection.resource();
539 let new_resource_id = format!(
540 "{}.{}.{}",
541 resource.id().expect("resource must have ID"),
542 config
543 .id_suffix
544 .as_ref()
545 .map(|s| s.as_str())
546 .unwrap_or("translation"),
547 seqnr
548 );
549 let new_filename = if let Some(filename) = resource.as_ref().filename() {
550 Some(format!(
551 "{}.{}.{}.txt",
552 if filename.ends_with(".txt") {
553 &filename[..filename.len() - 4]
554 } else if filename.ends_with(".md") {
555 &filename[..filename.len() - 3]
556 } else {
557 filename
558 },
559 config
560 .id_suffix
561 .as_ref()
562 .map(|s| s.as_str())
563 .unwrap_or("translation"),
564 seqnr
565 ))
566 } else {
567 None
568 };
569 translate_text_helper(
570 config,
571 store,
572 textselection.text(),
573 &resource,
574 textselection.begin(),
575 new_resource_id,
576 new_filename,
577 &mut resourcebuilders,
578 &mut annotations,
579 &constraint_queries,
580 )?;
581 }
582 _ => {
583 return Err(
584 "translatetext is only implemented for resources and text selections at the moment"
585 .into(),
586 );
587 }
588 }
589 }
590 }
591 }
592
593 Ok((resourcebuilders, annotations))
594}
595
596fn translate_text_helper<'store, 'a>(
597 config: &TranslateTextConfig,
598 store: &'store AnnotationStore,
599 text: &'store str,
600 resource: &ResultItem<'store, TextResource>,
601 baseoffset: usize,
602 new_resource_id: String,
603 new_filename: Option<String>,
604 resourcebuilders: &mut Vec<TextResourceBuilder>,
605 annotations: &mut Vec<AnnotationBuilder<'static>>,
606 constraint_queries: &HashMap<String, Query<'a>>,
607) -> Result<(), String> {
608 let mut new_text =
609 String::with_capacity(text.len() + (0.1 * text.len() as f64).round() as usize); let mut sourceselectors: Vec<SelectorBuilder<'static>> = Vec::new();
612 let mut targetselectors: Vec<SelectorBuilder<'static>> = Vec::new();
613
614 let mut skipbytes = 0;
615 let mut targetcharpos = 0;
616 for (charpos, (bytepos, c)) in text.char_indices().enumerate() {
617 if skipbytes > 0 {
618 skipbytes -= c.len_utf8();
619 continue;
620 }
621 let mut foundrule = false;
622 for rule in config.rules.iter().rev() {
623 if let Some(m) = rule.test(text, bytepos) {
624 if !rule.constraints.is_empty() {
625 let mut constraints_match = true; let sourcecharlen = m.source.chars().count();
627 let source = resource
628 .textselection(&Offset::simple(charpos, charpos + sourcecharlen))
629 .map_err(|e| format!("Failed to extract source: {}", e))?;
630 let left = resource
631 .textselection(&Offset::new(
632 Cursor::BeginAligned(0),
633 Cursor::BeginAligned(charpos),
634 ))
635 .map_err(|e| format!("Failed to extract left context: {}", e))?;
636 let right = resource
637 .textselection(&Offset::new(
638 Cursor::BeginAligned(charpos + sourcecharlen), Cursor::EndAligned(0),
640 ))
641 .map_err(|e| format!("Failed to extract right context: {}", e))?;
642 for constraint in rule.constraints.iter() {
643 let mut query = constraint_queries
645 .get(constraint.query.as_str())
646 .expect("constraint query should have been compiled earlier")
647 .clone();
648 query.bind_resourcevar("resource", resource);
649 query.bind_textvar("source", &source);
650 query.bind_textvar("left", &left);
651 query.bind_textvar("right", &right);
652 let mut iter = store
653 .query(query)
654 .map_err(|e| format!("Constraint query failed: {}", e))?;
655 if let Some(result) = iter.next() {
656 if let Some(testvar) = constraint.test.as_ref() {
658 if result.get_by_name(testvar.as_str()).is_ok() {
659 if constraint.invert {
660 constraints_match = false;
661 break;
662 }
663 } else if !constraint.invert {
664 constraints_match = false;
665 break;
666 }
667 } else if constraint.invert {
668 constraints_match = false;
670 break;
671 }
672 } else if !constraint.invert {
673 constraints_match = false;
675 break;
676 }
677 }
678 if !constraints_match {
679 if config.debug {
680 eprintln!(
681 "[stam translatetext] @{} failed to matched rule {:?} -> {:?} because of unmet constraints",
682 charpos, m.source, m.target
683 )
684 }
685 continue; }
687 }
688
689 skipbytes += m.source.len() - c.len_utf8(); if config.debug {
692 eprintln!(
693 "[stam translatetext] @{} (byte {}) matched rule {:?} -> {:?}",
694 charpos, bytepos, m.source, m.target
695 )
696 }
697
698 new_text += &m.target;
699
700 if !config.no_annotations {
701 let sourcelen = m.source.chars().count();
702 let targetlen = m.target.chars().count();
703 if targetlen == 0 && sourcelen > 1 {
704 for i in 0..sourcelen {
706 sourceselectors.push(SelectorBuilder::TextSelector(
707 resource.handle().into(),
708 Offset::simple(
709 baseoffset + charpos + i,
710 baseoffset + charpos + i + 1,
711 ),
712 ));
713 targetselectors.push(SelectorBuilder::TextSelector(
714 new_resource_id.clone().into(),
715 Offset::simple(targetcharpos, targetcharpos),
716 ));
717 }
718
719 } else {
722 sourceselectors.push(SelectorBuilder::TextSelector(
724 resource.handle().into(),
725 Offset::simple(baseoffset + charpos, baseoffset + charpos + sourcelen),
726 ));
727 targetselectors.push(SelectorBuilder::TextSelector(
728 new_resource_id.clone().into(),
729 Offset::simple(targetcharpos, targetcharpos + targetlen),
730 ));
731 }
732 targetcharpos += targetlen;
733 }
734
735 foundrule = true;
736 continue; }
738 }
739
740 if !foundrule && !config.discard_unmatched {
741 if config.debug {
742 eprintln!(
743 "[stam translatetext] @{} (byte {}) no rule matches {:?}, falling back",
744 charpos, bytepos, c
745 )
746 }
747 new_text.push(c);
749 if !config.no_annotations {
750 sourceselectors.push(SelectorBuilder::TextSelector(
751 resource.handle().into(),
752 Offset::simple(baseoffset + charpos, baseoffset + charpos + 1),
753 ));
754 targetselectors.push(SelectorBuilder::TextSelector(
755 new_resource_id.clone().into(),
756 Offset::simple(targetcharpos, targetcharpos + 1),
757 ));
758 }
759 targetcharpos += 1;
760 }
761 }
762
763 if !config.force_when_unchanged && new_text.as_str() == text {
764 eprintln!(
765 "[stam translatetext] text for {} has not changed after translation, skipping..",
766 new_resource_id
767 );
768 return Ok(());
769 }
770
771 if !config.no_annotations {
772 let source_id = format!("{}.translation-source", new_resource_id.as_str());
773 let target_id = format!("{}.translation-target", new_resource_id.as_str());
774 annotations.push(
775 AnnotationBuilder::new()
776 .with_id(source_id.as_str())
777 .with_target(SelectorBuilder::DirectionalSelector(sourceselectors)),
778 );
779 annotations.push(
780 AnnotationBuilder::new()
781 .with_id(target_id.as_str())
782 .with_target(SelectorBuilder::DirectionalSelector(targetselectors)),
783 );
784 annotations.push(
785 AnnotationBuilder::new()
786 .with_id(format!("{}.translation", new_resource_id.as_str()))
787 .with_data(
788 "https://w3id.org/stam/extensions/stam-translate/",
789 "Translation",
790 DataValue::Null,
791 )
792 .with_target(SelectorBuilder::DirectionalSelector(vec![
793 SelectorBuilder::AnnotationSelector(source_id.into(), None),
794 SelectorBuilder::AnnotationSelector(target_id.into(), None),
795 ])),
796 );
797 }
798
799 let mut resourcebuilder = TextResourceBuilder::new()
800 .with_text(new_text)
801 .with_id(new_resource_id);
802 if let Some(new_filename) = new_filename {
803 resourcebuilder = resourcebuilder.with_filename(new_filename);
804 }
805 resourcebuilders.push(resourcebuilder);
806 Ok(())
807}