Skip to content

Synctree module

The synctree module implements the logic that keeps locale trees in sync.

PageIndex

An in-memory index of pages to remove the need to query the database.

Each entry in the index is a unique page by translation key, so a page that has been translated into different languages appears only once.

Source code in wagtail_localize/synctree.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
class PageIndex:
    """
    An in-memory index of pages to remove the need to query the database.

    Each entry in the index is a unique page by translation key, so a page
    that has been translated into different languages appears only once.
    """

    # Note: This has been designed to be as memory-efficient as possible, but it
    # hasn't been tested on a very large site yet.

    class Entry:
        """
        Represents a page in the index.
        """

        __slots__ = [
            "content_type",
            "translation_key",
            "source_locale",
            "parent_translation_key",
            "locales",
            "aliased_locales",
        ]

        def __init__(
            self,
            content_type,
            translation_key,
            source_locale,
            parent_translation_key,
            locales,
            aliased_locales,
        ):
            self.content_type = content_type
            self.translation_key = translation_key
            self.source_locale = source_locale
            self.parent_translation_key = parent_translation_key
            self.locales = locales
            self.aliased_locales = aliased_locales

        REQUIRED_PAGE_FIELDS = [
            "content_type",
            "translation_key",
            "locale",
            "path",
            "depth",
            "last_published_at",
            "latest_revision_created_at",
            "live",
        ]

        @classmethod
        def from_page_instance(cls, page):
            """
            Initialises an Entry from the given page instance.
            """
            # Get parent, but only if the parent is not the root page. We consider the
            # homepage of each langauge tree to be the roots
            if page.depth > 2:
                parent_page = page.get_parent()
            else:
                parent_page = None

            return cls(
                page.content_type,
                page.translation_key,
                page.locale,
                parent_page.translation_key if parent_page else None,
                list(
                    Page.objects.filter(
                        translation_key=page.translation_key,
                        alias_of__isnull=True,
                    ).values_list("locale", flat=True)
                ),
                list(
                    Page.objects.filter(
                        translation_key=page.translation_key,
                        alias_of__isnull=False,
                    ).values_list("locale", flat=True)
                ),
            )

    def __init__(self, pages):
        self.pages = pages

    @cached_property
    def by_translation_key(self):
        return {page.translation_key: page for page in self.pages}

    @cached_property
    def by_parent_translation_key(self):
        by_parent_translation_key = defaultdict(list)
        for page in self.pages:
            by_parent_translation_key[page.parent_translation_key].append(page)

        return dict(by_parent_translation_key.items())

    def sort_by_tree_position(self):
        """
        Returns a new index with the pages sorted in depth-first-search order
        using their parent in their respective source locale.
        """
        remaining_pages = {page.translation_key for page in self.pages}

        new_pages = []

        def _walk(translation_key):
            for page in self.by_parent_translation_key.get(translation_key, []):
                if page.translation_key not in remaining_pages:
                    continue

                remaining_pages.remove(page.translation_key)
                new_pages.append(page)
                _walk(page.translation_key)

        _walk(None)

        if remaining_pages:
            logger.warning(f"{len(remaining_pages)} orphaned pages!")

        return PageIndex(new_pages)

    def not_translated_into(self, locale):
        """
        Returns an index of pages that are not translated into the specified locale.
        This includes pages that have and don't have a placeholder
        """
        pages = [page for page in self.pages if locale.id not in page.locales]

        return PageIndex(pages)

    def __iter__(self):
        return iter(self.pages)

    @classmethod
    def from_database(cls):
        """
        Populates the index from the database.
        """
        pages = []

        for page in Page.objects.filter(alias_of__isnull=True, depth__gt=1).only(
            *PageIndex.Entry.REQUIRED_PAGE_FIELDS
        ):
            pages.append(PageIndex.Entry.from_page_instance(page))

        return PageIndex(pages)

Entry

Represents a page in the index.

Source code in wagtail_localize/synctree.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class Entry:
    """
    Represents a page in the index.
    """

    __slots__ = [
        "content_type",
        "translation_key",
        "source_locale",
        "parent_translation_key",
        "locales",
        "aliased_locales",
    ]

    def __init__(
        self,
        content_type,
        translation_key,
        source_locale,
        parent_translation_key,
        locales,
        aliased_locales,
    ):
        self.content_type = content_type
        self.translation_key = translation_key
        self.source_locale = source_locale
        self.parent_translation_key = parent_translation_key
        self.locales = locales
        self.aliased_locales = aliased_locales

    REQUIRED_PAGE_FIELDS = [
        "content_type",
        "translation_key",
        "locale",
        "path",
        "depth",
        "last_published_at",
        "latest_revision_created_at",
        "live",
    ]

    @classmethod
    def from_page_instance(cls, page):
        """
        Initialises an Entry from the given page instance.
        """
        # Get parent, but only if the parent is not the root page. We consider the
        # homepage of each langauge tree to be the roots
        if page.depth > 2:
            parent_page = page.get_parent()
        else:
            parent_page = None

        return cls(
            page.content_type,
            page.translation_key,
            page.locale,
            parent_page.translation_key if parent_page else None,
            list(
                Page.objects.filter(
                    translation_key=page.translation_key,
                    alias_of__isnull=True,
                ).values_list("locale", flat=True)
            ),
            list(
                Page.objects.filter(
                    translation_key=page.translation_key,
                    alias_of__isnull=False,
                ).values_list("locale", flat=True)
            ),
        )

from_page_instance(page) classmethod

Initialises an Entry from the given page instance.

Source code in wagtail_localize/synctree.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@classmethod
def from_page_instance(cls, page):
    """
    Initialises an Entry from the given page instance.
    """
    # Get parent, but only if the parent is not the root page. We consider the
    # homepage of each langauge tree to be the roots
    if page.depth > 2:
        parent_page = page.get_parent()
    else:
        parent_page = None

    return cls(
        page.content_type,
        page.translation_key,
        page.locale,
        parent_page.translation_key if parent_page else None,
        list(
            Page.objects.filter(
                translation_key=page.translation_key,
                alias_of__isnull=True,
            ).values_list("locale", flat=True)
        ),
        list(
            Page.objects.filter(
                translation_key=page.translation_key,
                alias_of__isnull=False,
            ).values_list("locale", flat=True)
        ),
    )

from_database() classmethod

Populates the index from the database.

Source code in wagtail_localize/synctree.py
148
149
150
151
152
153
154
155
156
157
158
159
160
@classmethod
def from_database(cls):
    """
    Populates the index from the database.
    """
    pages = []

    for page in Page.objects.filter(alias_of__isnull=True, depth__gt=1).only(
        *PageIndex.Entry.REQUIRED_PAGE_FIELDS
    ):
        pages.append(PageIndex.Entry.from_page_instance(page))

    return PageIndex(pages)

not_translated_into(locale)

Returns an index of pages that are not translated into the specified locale. This includes pages that have and don't have a placeholder

Source code in wagtail_localize/synctree.py
136
137
138
139
140
141
142
143
def not_translated_into(self, locale):
    """
    Returns an index of pages that are not translated into the specified locale.
    This includes pages that have and don't have a placeholder
    """
    pages = [page for page in self.pages if locale.id not in page.locales]

    return PageIndex(pages)

sort_by_tree_position()

Returns a new index with the pages sorted in depth-first-search order using their parent in their respective source locale.

Source code in wagtail_localize/synctree.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def sort_by_tree_position(self):
    """
    Returns a new index with the pages sorted in depth-first-search order
    using their parent in their respective source locale.
    """
    remaining_pages = {page.translation_key for page in self.pages}

    new_pages = []

    def _walk(translation_key):
        for page in self.by_parent_translation_key.get(translation_key, []):
            if page.translation_key not in remaining_pages:
                continue

            remaining_pages.remove(page.translation_key)
            new_pages.append(page)
            _walk(page.translation_key)

    _walk(None)

    if remaining_pages:
        logger.warning(f"{len(remaining_pages)} orphaned pages!")

    return PageIndex(new_pages)

synchronize_tree(source_locale, target_locale, *, page_index=None)

Synchronises a locale tree with an other locale.

This creates alias pages for pages that are not translated yet.

Parameters:

Name Type Description Default
source_locale Locale

The Locale to sync from.

required
target_locale Locale

The Locale to sync into

required
page_index PageIndex

The Page index to reuse for performance. Otherwise will generate a new one.

None
Source code in wagtail_localize/synctree.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def synchronize_tree(source_locale, target_locale, *, page_index=None):
    """
    Synchronises a locale tree with an other locale.

    This creates alias pages for pages that are not translated yet.

    Args:
        source_locale (Locale): The Locale to sync from.
        target_locale (Locale): The Locale to sync into
        page_index (PageIndex, optional): The Page index to reuse for performance. Otherwise will generate a new one.

    """
    # Build a page index
    if not page_index:
        page_index = PageIndex.from_database().sort_by_tree_position()

    # Find pages that are not translated for this locale
    # This includes locales that have a placeholder, it only excludes locales that have an actual translation
    pages_not_in_locale = page_index.not_translated_into(target_locale)

    for page in pages_not_in_locale:
        # Skip pages that do not exist in the source
        if (
            source_locale.id not in page.locales
            and source_locale.id not in page.aliased_locales
        ):
            continue

        # Fetch source from database
        model = page.content_type.model_class()
        source_page = model.objects.get(
            translation_key=page.translation_key, locale=source_locale
        )

        if target_locale.id not in page.aliased_locales:
            source_page.copy_for_translation(
                target_locale, copy_parents=True, alias=True
            )