1"""Objects representing API interface to MediaWiki site extenstions."""
2#
3# (C) Pywikibot team, 2008-2021
4#
5# Distributed under the terms of the MIT license.
6#
7import pywikibot
8import pywikibot.family
9from pywikibot.data import api
10from pywikibot.echo import Notification
11from pywikibot.exceptions import (
12    APIError,
13    InconsistentTitleError,
14    SiteDefinitionError,
15)
16from pywikibot.site._decorators import need_extension, need_right
17from pywikibot.tools import deprecate_arg, deprecated_args, merge_unique_dicts
18
19
20class EchoMixin:
21
22    """APISite mixin for Echo extension."""
23
24    @need_extension('Echo')
25    def notifications(self, **kwargs):
26        """Yield Notification objects from the Echo extension.
27
28        :keyword format: If specified, notifications will be returned formatted
29            this way. Its value is either 'model', 'special' or None. Default
30            is 'special'.
31        :type format: str or None
32
33        Refer API reference for other keywords.
34        """
35        params = {
36            'action': 'query',
37            'meta': 'notifications',
38            'notformat': 'special',
39        }
40
41        for key, value in kwargs.items():
42            params['not' + key] = value
43
44        data = self._simple_request(**params).submit()
45        notifications = data['query']['notifications']['list']
46
47        # Support API before 1.27.0-wmf.22
48        if hasattr(notifications, 'values'):
49            notifications = notifications.values()
50
51        return (Notification.fromJSON(self, notification)
52                for notification in notifications)
53
54    @need_extension('Echo')
55    def notifications_mark_read(self, **kwargs):
56        """Mark selected notifications as read.
57
58        :return: whether the action was successful
59        :rtype: bool
60        """
61        # TODO: ensure that the 'echomarkread' action
62        # is supported by the site
63        kwargs = merge_unique_dicts(kwargs, action='echomarkread',
64                                    token=self.tokens['edit'])
65        req = self._simple_request(**kwargs)
66        data = req.submit()
67        try:
68            return data['query']['echomarkread']['result'] == 'success'
69        except KeyError:
70            return False
71
72
73class ProofreadPageMixin:
74
75    """APISite mixin for ProofreadPage extension."""
76
77    @need_extension('ProofreadPage')
78    def _cache_proofreadinfo(self, expiry=False):
79        """Retrieve proofreadinfo from site and cache response.
80
81        Applicable only to sites with ProofreadPage extension installed.
82
83        The following info is returned by the query and cached:
84        - self._proofread_index_ns: Index Namespace
85        - self._proofread_page_ns: Page Namespace
86        - self._proofread_levels: a dictionary with:
87                keys: int in the range [0, 1, ..., 4]
88                values: category name corresponding to the 'key' quality level
89            e.g. on en.wikisource:
90            {0: 'Without text', 1: 'Not proofread', 2: 'Problematic',
91             3: 'Proofread', 4: 'Validated'}
92
93        :param expiry: either a number of days or a datetime.timedelta object
94        :type expiry: int (days), :py:obj:`datetime.timedelta`, False (config)
95        :return: A tuple containing _proofread_index_ns,
96            self._proofread_page_ns and self._proofread_levels.
97        :rtype: Namespace, Namespace, dict
98        """
99        if (not hasattr(self, '_proofread_index_ns')
100                or not hasattr(self, '_proofread_page_ns')
101                or not hasattr(self, '_proofread_levels')):
102
103            pirequest = self._request(
104                expiry=pywikibot.config.API_config_expiry
105                if expiry is False else expiry,
106                parameters={'action': 'query', 'meta': 'proofreadinfo'}
107            )
108
109            pidata = pirequest.submit()
110            ns_id = pidata['query']['proofreadnamespaces']['index']['id']
111            self._proofread_index_ns = self.namespaces[ns_id]
112
113            ns_id = pidata['query']['proofreadnamespaces']['page']['id']
114            self._proofread_page_ns = self.namespaces[ns_id]
115
116            self._proofread_levels = {}
117            for ql in pidata['query']['proofreadqualitylevels']:
118                self._proofread_levels[ql['id']] = ql['category']
119
120    @property
121    def proofread_index_ns(self):
122        """Return Index namespace for the ProofreadPage extension."""
123        if not hasattr(self, '_proofread_index_ns'):
124            self._cache_proofreadinfo()
125        return self._proofread_index_ns
126
127    @property
128    def proofread_page_ns(self):
129        """Return Page namespace for the ProofreadPage extension."""
130        if not hasattr(self, '_proofread_page_ns'):
131            self._cache_proofreadinfo()
132        return self._proofread_page_ns
133
134    @property
135    def proofread_levels(self):
136        """Return Quality Levels for the ProofreadPage extension."""
137        if not hasattr(self, '_proofread_levels'):
138            self._cache_proofreadinfo()
139        return self._proofread_levels
140
141
142class GeoDataMixin:
143
144    """APISite mixin for GeoData extension."""
145
146    @need_extension('GeoData')
147    def loadcoordinfo(self, page):
148        """Load [[mw:Extension:GeoData]] info."""
149        title = page.title(with_section=False)
150        query = self._generator(api.PropertyGenerator,
151                                type_arg='coordinates',
152                                titles=title.encode(self.encoding()),
153                                coprop=['type', 'name', 'dim',
154                                        'country', 'region',
155                                        'globe'],
156                                coprimary='all')
157        self._update_page(page, query)
158
159
160class PageImagesMixin:
161
162    """APISite mixin for PageImages extension."""
163
164    @need_extension('PageImages')
165    def loadpageimage(self, page):
166        """
167        Load [[mw:Extension:PageImages]] info.
168
169        :param page: The page for which to obtain the image
170        :type page: pywikibot.Page
171
172        :raises APIError: PageImages extension is not installed
173        """
174        title = page.title(with_section=False)
175        query = self._generator(api.PropertyGenerator,
176                                type_arg='pageimages',
177                                titles=title.encode(self.encoding()),
178                                piprop=['name'])
179        self._update_page(page, query)
180
181
182class GlobalUsageMixin:
183
184    """APISite mixin for Global Usage extension."""
185
186    @need_extension('Global Usage')
187    def globalusage(self, page, total=None):
188        """Iterate global image usage for a given FilePage.
189
190        :param page: the page to return global image usage for.
191        :type page: pywikibot.FilePage
192        :param total: iterate no more than this number of pages in total.
193        :raises TypeError: input page is not a FilePage.
194        :raises pywikibot.exceptions.SiteDefinitionError: Site could not be
195            defined for a returned entry in API response.
196        """
197        if not isinstance(page, pywikibot.FilePage):
198            raise TypeError('Page {} must be a FilePage.'.format(page))
199
200        title = page.title(with_section=False)
201        args = {'titles': title,
202                'gufilterlocal': False,
203                }
204        query = self._generator(api.PropertyGenerator,
205                                type_arg='globalusage',
206                                guprop=['url', 'pageid', 'namespace'],
207                                total=total,  # will set gulimit=total in api,
208                                **args)
209
210        for pageitem in query:
211            if not self.sametitle(pageitem['title'],
212                                  page.title(with_section=False)):
213                raise InconsistentTitleError(page, pageitem['title'])
214
215            api.update_page(page, pageitem, query.props)
216
217            assert 'globalusage' in pageitem, \
218                   "API globalusage response lacks 'globalusage' key"
219            for entry in pageitem['globalusage']:
220                try:
221                    gu_site = pywikibot.Site(url=entry['url'])
222                except SiteDefinitionError:
223                    pywikibot.warning(
224                        'Site could not be defined for global'
225                        ' usage for {}: {}.'.format(page, entry))
226                    continue
227                gu_page = pywikibot.Page(gu_site, entry['title'])
228                yield gu_page
229
230
231class WikibaseClientMixin:
232
233    """APISite mixin for WikibaseClient extension."""
234
235    @deprecated_args(step=True)
236    @need_extension('WikibaseClient')
237    def unconnected_pages(self, total=None):
238        """Yield Page objects from Special:UnconnectedPages.
239
240        :param total: number of pages to return
241        """
242        return self.querypage('UnconnectedPages', total)
243
244
245class LinterMixin:
246
247    """APISite mixin for Linter extension."""
248
249    @need_extension('Linter')
250    def linter_pages(self, lint_categories=None, total=None,
251                     namespaces=None, pageids=None, lint_from=None):
252        """Return a generator to pages containing linter errors.
253
254        :param lint_categories: categories of lint errors
255        :type lint_categories: an iterable that returns values (str),
256            or a pipe-separated string of values.
257
258        :param total: if not None, yielding this many items in total
259        :type total: int
260
261        :param namespaces: only iterate pages in these namespaces
262        :type namespaces: iterable of str or Namespace key,
263            or a single instance of those types. May be a '|' separated
264            list of namespace identifiers.
265
266        :param pageids: only include lint errors from the specified pageids
267        :type pageids: an iterable that returns pageids (str or int),
268            or a comma- or pipe-separated string of pageids
269            (e.g. '945097,1483753, 956608' or '945097|483753|956608')
270
271        :param lint_from: Lint ID to start querying from
272        :type lint_from: str representing digit or integer
273
274        :return: pages with Linter errors.
275        :rtype: typing.Iterable[pywikibot.Page]
276        """
277        query = self._generator(api.ListGenerator, type_arg='linterrors',
278                                total=total,  # Will set lntlimit
279                                namespaces=namespaces)
280
281        if lint_categories:
282            if isinstance(lint_categories, str):
283                lint_categories = lint_categories.split('|')
284                lint_categories = [p.strip() for p in lint_categories]
285            query.request['lntcategories'] = '|'.join(lint_categories)
286
287        if pageids:
288            if isinstance(pageids, str):
289                pageids = pageids.split('|')
290                pageids = [p.strip() for p in pageids]
291            # Validate pageids.
292            pageids = (str(int(p)) for p in pageids if int(p) > 0)
293            query.request['lntpageid'] = '|'.join(pageids)
294
295        if lint_from:
296            query.request['lntfrom'] = int(lint_from)
297
298        for pageitem in query:
299            page = pywikibot.Page(self, pageitem['title'])
300            api.update_page(page, pageitem)
301            yield page
302
303
304class ThanksMixin:
305
306    """APISite mixin for Thanks extension."""
307
308    @need_extension('Thanks')
309    def thank_revision(self, revid, source=None):
310        """Corresponding method to the 'action=thank' API action.
311
312        :param revid: Revision ID for the revision to be thanked.
313        :type revid: int
314        :param source: A source for the thanking operation.
315        :type source: str
316        :raise APIError: On thanking oneself or other API errors.
317        :return: The API response.
318        """
319        token = self.tokens['csrf']
320        req = self._simple_request(action='thank', rev=revid, token=token,
321                                   source=source)
322        data = req.submit()
323        if data['result']['success'] != 1:
324            raise APIError('Thanking unsuccessful', '')
325        return data
326
327
328class ThanksFlowMixin:
329
330    """APISite mixin for Thanks and Flow extension."""
331
332    @need_extension('Flow')
333    @need_extension('Thanks')
334    def thank_post(self, post):
335        """Corresponding method to the 'action=flowthank' API action.
336
337        :param post: The post to be thanked for.
338        :type post: Post
339        :raise APIError: On thanking oneself or other API errors.
340        :return: The API response.
341        """
342        post_id = post.uuid
343        token = self.tokens['csrf']
344        req = self._simple_request(action='flowthank',
345                                   postid=post_id, token=token)
346        data = req.submit()
347        if data['result']['success'] != 1:
348            raise APIError('Thanking unsuccessful', '')
349        return data
350
351
352class FlowMixin:
353
354    """APISite mixin for Flow extension."""
355
356    @need_extension('Flow')
357    def load_board(self, page):
358        """
359        Retrieve the data for a Flow board.
360
361        :param page: A Flow board
362        :type page: Board
363        :return: A dict representing the board's metadata.
364        :rtype: dict
365        """
366        req = self._simple_request(action='flow', page=page,
367                                   submodule='view-topiclist',
368                                   vtllimit=1)
369        data = req.submit()
370        return data['flow']['view-topiclist']['result']['topiclist']
371
372    @need_extension('Flow')
373    @deprecate_arg('format', 'content_format')
374    def load_topiclist(self, page, content_format: str = 'wikitext', limit=100,
375                       sortby='newest', toconly=False, offset=None,
376                       offset_id=None, reverse=False, include_offset=False):
377        """
378        Retrieve the topiclist of a Flow board.
379
380        :param page: A Flow board
381        :type page: Board
382        :param content_format: The content format to request the data in.
383            must be either 'wikitext', 'html', or 'fixed-html'
384        :param limit: The number of topics to fetch in each request.
385        :type limit: int
386        :param sortby: Algorithm to sort topics by.
387        :type sortby: str (either 'newest' or 'updated')
388        :param toconly: Whether to only include information for the TOC.
389        :type toconly: bool
390        :param offset: The timestamp to start at (when sortby is 'updated').
391        :type offset: Timestamp or equivalent str
392        :param offset_id: The topic UUID to start at (when sortby is 'newest').
393        :type offset_id: str (in the form of a UUID)
394        :param reverse: Whether to reverse the topic ordering.
395        :type reverse: bool
396        :param include_offset: Whether to include the offset topic.
397        :type include_offset: bool
398        :return: A dict representing the board's topiclist.
399        :rtype: dict
400        """
401        if offset:
402            offset = pywikibot.Timestamp.fromtimestampformat(offset)
403        offset_dir = 'rev' if reverse else 'fwd'
404
405        params = {'action': 'flow', 'submodule': 'view-topiclist',
406                  'page': page,
407                  'vtlformat': content_format, 'vtlsortby': sortby,
408                  'vtllimit': limit, 'vtloffset-dir': offset_dir,
409                  'vtloffset': offset, 'vtloffset-id': offset_id,
410                  'vtlinclude-offset': include_offset, 'vtltoconly': toconly}
411        req = self._request(parameters=params)
412        data = req.submit()
413        return data['flow']['view-topiclist']['result']['topiclist']
414
415    @need_extension('Flow')
416    @deprecate_arg('format', 'content_format')
417    def load_topic(self, page, content_format: str):
418        """
419        Retrieve the data for a Flow topic.
420
421        :param page: A Flow topic
422        :type page: Topic
423        :param content_format: The content format to request the data in.
424            Must ne either 'wikitext', 'html', or 'fixed-html'
425        :return: A dict representing the topic's data.
426        :rtype: dict
427        """
428        req = self._simple_request(action='flow', page=page,
429                                   submodule='view-topic',
430                                   vtformat=content_format)
431        data = req.submit()
432        return data['flow']['view-topic']['result']['topic']
433
434    @need_extension('Flow')
435    @deprecate_arg('format', 'content_format')
436    def load_post_current_revision(self, page, post_id, content_format: str):
437        """
438        Retrieve the data for a post to a Flow topic.
439
440        :param page: A Flow topic
441        :type page: Topic
442        :param post_id: The UUID of the Post
443        :type post_id: str
444        :param content_format: The content format used for the returned
445            content; must be either 'wikitext', 'html', or 'fixed-html'
446        :return: A dict representing the post data for the given UUID.
447        :rtype: dict
448        """
449        req = self._simple_request(action='flow', page=page,
450                                   submodule='view-post', vppostId=post_id,
451                                   vpformat=content_format)
452        data = req.submit()
453        return data['flow']['view-post']['result']['topic']
454
455    @need_right('edit')
456    @need_extension('Flow')
457    @deprecate_arg('format', 'content_format')
458    def create_new_topic(self, page, title, content, content_format):
459        """
460        Create a new topic on a Flow board.
461
462        :param page: A Flow board
463        :type page: Board
464        :param title: The title of the new topic (must be in plaintext)
465        :type title: str
466        :param content: The content of the topic's initial post
467        :type content: str
468        :param content_format: The content format of the supplied content
469        :type content_format: str (either 'wikitext' or 'html')
470        :return: The metadata of the new topic
471        :rtype: dict
472        """
473        token = self.tokens['csrf']
474        params = {'action': 'flow', 'page': page, 'token': token,
475                  'submodule': 'new-topic', 'ntformat': content_format,
476                  'nttopic': title, 'ntcontent': content}
477        req = self._request(parameters=params, use_get=False)
478        data = req.submit()
479        return data['flow']['new-topic']['committed']['topiclist']
480
481    @need_right('edit')
482    @need_extension('Flow')
483    @deprecate_arg('format', 'content_format')
484    def reply_to_post(self, page, reply_to_uuid: str, content: str,
485                      content_format: str) -> dict:
486        """Reply to a post on a Flow topic.
487
488        :param page: A Flow topic
489        :type page: Topic
490        :param reply_to_uuid: The UUID of the Post to create a reply to
491        :param content: The content of the reply
492        :param content_format: The content format used for the supplied
493            content; must be either 'wikitext' or 'html'
494        :return: Metadata returned by the API
495        """
496        token = self.tokens['csrf']
497        params = {'action': 'flow', 'page': page, 'token': token,
498                  'submodule': 'reply', 'repreplyTo': reply_to_uuid,
499                  'repcontent': content, 'repformat': content_format}
500        req = self._request(parameters=params, use_get=False)
501        data = req.submit()
502        return data['flow']['reply']['committed']['topic']
503
504    @need_right('flow-lock')
505    @need_extension('Flow')
506    def lock_topic(self, page, lock, reason):
507        """
508        Lock or unlock a Flow topic.
509
510        :param page: A Flow topic
511        :type page: Topic
512        :param lock: Whether to lock or unlock the topic
513        :type lock: bool (True corresponds to locking the topic.)
514        :param reason: The reason to lock or unlock the topic
515        :type reason: str
516        :return: Metadata returned by the API
517        :rtype: dict
518        """
519        status = 'lock' if lock else 'unlock'
520        token = self.tokens['csrf']
521        params = {'action': 'flow', 'page': page, 'token': token,
522                  'submodule': 'lock-topic', 'cotreason': reason,
523                  'cotmoderationState': status}
524        req = self._request(parameters=params, use_get=False)
525        data = req.submit()
526        return data['flow']['lock-topic']['committed']['topic']
527
528    @need_right('edit')
529    @need_extension('Flow')
530    def moderate_topic(self, page, state, reason):
531        """
532        Moderate a Flow topic.
533
534        :param page: A Flow topic
535        :type page: Topic
536        :param state: The new moderation state
537        :type state: str
538        :param reason: The reason to moderate the topic
539        :type reason: str
540        :return: Metadata returned by the API
541        :rtype: dict
542        """
543        token = self.tokens['csrf']
544        params = {'action': 'flow', 'page': page, 'token': token,
545                  'submodule': 'moderate-topic', 'mtreason': reason,
546                  'mtmoderationState': state}
547        req = self._request(parameters=params, use_get=False)
548        data = req.submit()
549        return data['flow']['moderate-topic']['committed']['topic']
550
551    @need_right('flow-delete')
552    @need_extension('Flow')
553    def delete_topic(self, page, reason):
554        """
555        Delete a Flow topic.
556
557        :param page: A Flow topic
558        :type page: Topic
559        :param reason: The reason to delete the topic
560        :type reason: str
561        :return: Metadata returned by the API
562        :rtype: dict
563        """
564        return self.moderate_topic(page, 'delete', reason)
565
566    @need_right('flow-hide')
567    @need_extension('Flow')
568    def hide_topic(self, page, reason):
569        """
570        Hide a Flow topic.
571
572        :param page: A Flow topic
573        :type page: Topic
574        :param reason: The reason to hide the topic
575        :type reason: str
576        :return: Metadata returned by the API
577        :rtype: dict
578        """
579        return self.moderate_topic(page, 'hide', reason)
580
581    @need_right('flow-suppress')
582    @need_extension('Flow')
583    def suppress_topic(self, page, reason):
584        """
585        Suppress a Flow topic.
586
587        :param page: A Flow topic
588        :type page: Topic
589        :param reason: The reason to suppress the topic
590        :type reason: str
591        :return: Metadata returned by the API
592        :rtype: dict
593        """
594        return self.moderate_topic(page, 'suppress', reason)
595
596    @need_right('edit')
597    @need_extension('Flow')
598    def restore_topic(self, page, reason):
599        """
600        Restore a Flow topic.
601
602        :param page: A Flow topic
603        :type page: Topic
604        :param reason: The reason to restore the topic
605        :type reason: str
606        :return: Metadata returned by the API
607        :rtype: dict
608        """
609        return self.moderate_topic(page, 'restore', reason)
610
611    @need_right('edit')
612    @need_extension('Flow')
613    def moderate_post(self, post, state, reason):
614        """
615        Moderate a Flow post.
616
617        :param post: A Flow post
618        :type post: Post
619        :param state: The new moderation state
620        :type state: str
621        :param reason: The reason to moderate the topic
622        :type reason: str
623        :return: Metadata returned by the API
624        :rtype: dict
625        """
626        page = post.page
627        uuid = post.uuid
628        token = self.tokens['csrf']
629        params = {'action': 'flow', 'page': page, 'token': token,
630                  'submodule': 'moderate-post', 'mpreason': reason,
631                  'mpmoderationState': state, 'mppostId': uuid}
632        req = self._request(parameters=params, use_get=False)
633        data = req.submit()
634        return data['flow']['moderate-post']['committed']['topic']
635
636    @need_right('flow-delete')
637    @need_extension('Flow')
638    def delete_post(self, post, reason):
639        """
640        Delete a Flow post.
641
642        :param post: A Flow post
643        :type post: Post
644        :param reason: The reason to delete the post
645        :type reason: str
646        :return: Metadata returned by the API
647        :rtype: dict
648        """
649        return self.moderate_post(post, 'delete', reason)
650
651    @need_right('flow-hide')
652    @need_extension('Flow')
653    def hide_post(self, post, reason):
654        """
655        Hide a Flow post.
656
657        :param post: A Flow post
658        :type post: Post
659        :param reason: The reason to hide the post
660        :type reason: str
661        :return: Metadata returned by the API
662        :rtype: dict
663        """
664        return self.moderate_post(post, 'hide', reason)
665
666    @need_right('flow-suppress')
667    @need_extension('Flow')
668    def suppress_post(self, post, reason):
669        """
670        Suppress a Flow post.
671
672        :param post: A Flow post
673        :type post: Post
674        :param reason: The reason to suppress the post
675        :type reason: str
676        :return: Metadata returned by the API
677        :rtype: dict
678        """
679        return self.moderate_post(post, 'suppress', reason)
680
681    @need_right('edit')
682    @need_extension('Flow')
683    def restore_post(self, post, reason):
684        """
685        Restore a Flow post.
686
687        :param post: A Flow post
688        :type post: Post
689        :param reason: The reason to restore the post
690        :type reason: str
691        :return: Metadata returned by the API
692        :rtype: dict
693        """
694        return self.moderate_post(post, 'restore', reason)
695
696
697class UrlShortenerMixin:
698
699    """APISite mixin for UrlShortener extension."""
700
701    @need_extension('UrlShortener')
702    def create_short_link(self, url):
703        """
704        Return a shortened link.
705
706        Note that on Wikimedia wikis only metawiki supports this action,
707        and this wiki can process links to all WM domains.
708
709        :param url: The link to reduce, with propotol prefix.
710        :type url: str
711        :return: The reduced link, without protocol prefix.
712        :rtype: str
713        """
714        req = self._simple_request(action='shortenurl', url=url)
715        data = req.submit()
716        return data['shortenurl']['shorturl']
717