|
| 1 | +import sublime |
| 2 | +import sublime_plugin |
| 3 | +import re |
| 4 | +import webbrowser |
| 5 | + |
| 6 | +from pathlib import Path |
| 7 | +from urllib import request |
| 8 | +from urllib.error import HTTPError |
| 9 | +from datetime import datetime |
| 10 | +from .utils import * |
| 11 | + |
| 12 | +__author__ = 'Aziz Köksal' |
| 13 | + |
| 14 | +__status__ = 'Production' |
| 15 | + |
| 16 | +HEXDOCS_URL = 'https://hexdocs.pm' |
| 17 | +HEX_URL = 'https://hex.pm' |
| 18 | +ELIXIR_CORE_APP_NAMES = ['eex', 'elixir', 'ex_unit', 'hex', 'iex', 'logger', 'mix'] |
| 19 | +name_lastmod_rx = r'hexdocs.pm/([^/]+)/[\s\S]+?<lastmod>([^<]+)</lastmod>' |
| 20 | +PROJECT_MAX_AGE_DAYS = 365 |
| 21 | + |
| 22 | +class SearchHexPackagesCommand(sublime_plugin.WindowCommand): |
| 23 | + def description(self): |
| 24 | + return 'Searches hex.pm and shows the results.' |
| 25 | + |
| 26 | + def run(self, **kwargs): |
| 27 | + query = (kwargs.get('query') or '').strip() |
| 28 | + |
| 29 | + if query: |
| 30 | + print_status_msg('Searching hex.pm for %r' % query) |
| 31 | + sublime.set_timeout_async(lambda: search_hex_pm(self.window, query)) |
| 32 | + |
| 33 | + def input(self, _args): |
| 34 | + class QueryInputHandler(sublime_plugin.TextInputHandler): |
| 35 | + def placeholder(self): return 'Search hex.pm' |
| 36 | + def validate(self, text): return text.strip() != '' |
| 37 | + |
| 38 | + return QueryInputHandler() |
| 39 | + |
| 40 | +class OpenHexDocsCommand(sublime_plugin.WindowCommand): |
| 41 | + def description(self): |
| 42 | + return 'Finds and opens hex documentation in the browser.' |
| 43 | + |
| 44 | + def run(self, **_kwargs): |
| 45 | + cache_dir = Path(sublime.cache_path(), 'ElixirSyntax') |
| 46 | + cache_dir.exists() or cache_dir.mkdir(parents=True) |
| 47 | + |
| 48 | + cached_sitemap_json_path = Path(cache_dir, 'hexdocs.sitemap.json') |
| 49 | + |
| 50 | + sitemap_dict = {} |
| 51 | + sitemap_url = HEXDOCS_URL + '/sitemap.xml' |
| 52 | + |
| 53 | + if cached_sitemap_json_path.exists(): |
| 54 | + sitemap_dict = load_json_file(cached_sitemap_json_path) |
| 55 | + etag = sitemap_dict['etag'] |
| 56 | + |
| 57 | + def refresh_sitemap(): |
| 58 | + try: |
| 59 | + resp = request.urlopen(request.Request(sitemap_url, headers={'If-None-Match': etag})) |
| 60 | + sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path) |
| 61 | + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) |
| 62 | + except HTTPError as e: |
| 63 | + e.code == 304 or print_status_msg('Error: %s' % e) |
| 64 | + |
| 65 | + sublime.set_timeout_async(refresh_sitemap) |
| 66 | + |
| 67 | + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) |
| 68 | + else: |
| 69 | + print_status_msg('Downloading %r' % sitemap_url) |
| 70 | + |
| 71 | + def fetch_sitemap(): |
| 72 | + try: |
| 73 | + resp = request.urlopen(sitemap_url) |
| 74 | + sitemap_dict = fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path) |
| 75 | + show_hexdocs_list(self.window, sitemap_dict.get('projects', [])) |
| 76 | + except HTTPError as e: |
| 77 | + print_status_msg('Error: could not fetch %r (status=#%s)' % (sitemap_url, resp.code)) |
| 78 | + |
| 79 | + sublime.set_timeout_async(fetch_sitemap) |
| 80 | + |
| 81 | +def search_hex_pm(window, query, **kwargs): |
| 82 | + """ Searches hex.pm and shows the results in a quick panel overlay. """ |
| 83 | + page = kwargs.get('page') |
| 84 | + page_param = page and ['page=%s' % page] or [] |
| 85 | + query = query and ''.join('%%%x' % ord(c) if c in '#&/?' else c for c in query) |
| 86 | + get_params = '&'.join(['search=%s' % query, 'sort=recent_downloads'] + page_param) |
| 87 | + query_url = HEX_URL + '/packages?' + get_params |
| 88 | + resp = request.urlopen(query_url) |
| 89 | + results_html = resp.read().decode('utf-8') |
| 90 | + |
| 91 | + package_list_match = re.search(r'<div class="package-list">([\s\S]+?)\n</div>', results_html) |
| 92 | + page_match = re.search(r'<li class="active">[\s\S]+?</li>[\s\S]+?\bpage=(\d+)', results_html) |
| 93 | + next_page = page_match and int(page_match.group(1)) |
| 94 | + total_count_match = re.search(r'packages of (\d+) total', results_html) |
| 95 | + total_packages_count = total_count_match and total_count_match.group(1) |
| 96 | + |
| 97 | + if not package_list_match: |
| 98 | + has_no_results = 'no-results' in results_html |
| 99 | + |
| 100 | + msg = [ |
| 101 | + 'could not find div.package-list in the results HTML.', |
| 102 | + 'no results found for %r on hex.pm!' % query |
| 103 | + ][has_no_results] |
| 104 | + |
| 105 | + if has_no_results: |
| 106 | + overlay_args = {'overlay': 'command_palette', 'command': 'search_hex_packages'} |
| 107 | + window.run_command('show_overlay', overlay_args) |
| 108 | + window.run_command('insert', {'characters': query}) |
| 109 | + |
| 110 | + print_status_msg('Error: ' + msg) |
| 111 | + return |
| 112 | + |
| 113 | + package_matches = re.findall(r'''(?xi) |
| 114 | + <span\sclass="download-count"> (.+?) </span> [\s\S]*? |
| 115 | + <span.+?> total\sdownloads:\s (.+?) </span> [\s\S]*? |
| 116 | + <a.+?> (.+?) </a> [\s\S]*? |
| 117 | + <span.+?> (.+?) </span> [\s\S]*? |
| 118 | + <p> ([^<]*) </p> |
| 119 | + ''', |
| 120 | + package_list_match.group(1) |
| 121 | + ) |
| 122 | + |
| 123 | + previous_results = kwargs.get('previous_results') or [] |
| 124 | + |
| 125 | + results = previous_results + [ |
| 126 | + {'name': m[2], 'desc': m[4], 'version': m[3], 'recent_dls': m[0], 'total_dls': m[1], |
| 127 | + 'url': HEX_URL + '/packages/' + m[2]} |
| 128 | + for m in package_matches |
| 129 | + ] |
| 130 | + |
| 131 | + selectable_results = results + [ |
| 132 | + {'label': 'Open search query in browser', 'url': query_url, 'desc': 'Terms: %s' % query}, |
| 133 | + ] + ( |
| 134 | + next_page and [{ |
| 135 | + 'label': 'Load page %d' % next_page, |
| 136 | + 'page': next_page, |
| 137 | + 'desc': 'Total packages found: %s' % (total_packages_count or 'unknown') |
| 138 | + }] or [] |
| 139 | + ) |
| 140 | + |
| 141 | + def on_select(i): |
| 142 | + if i >= 0: |
| 143 | + result = selectable_results[i] |
| 144 | + if result.get('page'): |
| 145 | + print_status_msg('Loading page %d on hex.pm for %r' % (next_page, query)) |
| 146 | + cb = lambda: search_hex_pm(window, query, page=next_page, previous_results=results) |
| 147 | + sublime.set_timeout_async(cb) |
| 148 | + else: |
| 149 | + webbrowser.open_new_tab(result['url']) |
| 150 | + |
| 151 | + placeholder = 'Open a project in the web browser.' |
| 152 | + selected_index = len(previous_results) if previous_results else -1 |
| 153 | + |
| 154 | + result_items = [ |
| 155 | + sublime.QuickPanelItem( |
| 156 | + trigger=result.get('label') or '%s v%s' % (result['name'], result['version']), |
| 157 | + details=result.get('desc') or '', |
| 158 | + annotation=result.get('recent_dls') \ |
| 159 | + and '%s recent / %s total downloads' % (result['recent_dls'], result['total_dls']) \ |
| 160 | + or '', |
| 161 | + kind=result.get('recent_dls') and sublime.KIND_NAVIGATION or sublime.KIND_AMBIGUOUS |
| 162 | + ) |
| 163 | + for result in selectable_results |
| 164 | + ] |
| 165 | + |
| 166 | + window.show_quick_panel(result_items, on_select, |
| 167 | + placeholder=placeholder, selected_index=selected_index |
| 168 | + ) |
| 169 | + |
| 170 | +def fetch_parse_and_save_sitemap(resp, cached_sitemap_json_path): |
| 171 | + """ Fetches, parses and saves the sitemap items in a JSON file. """ |
| 172 | + etag = next( |
| 173 | + (value for (header, value) in resp.headers.items() if header.lower() == 'etag'), None |
| 174 | + ) |
| 175 | + |
| 176 | + sitemap_xml = resp.read().decode('utf-8') |
| 177 | + elixir_core_projects = [(name, None) for name in ELIXIR_CORE_APP_NAMES] |
| 178 | + hexdocs_projects = re.findall(name_lastmod_rx, sitemap_xml) |
| 179 | + young_projects, old_projects, now = [], [], datetime.now() |
| 180 | + |
| 181 | + for name, date in hexdocs_projects: |
| 182 | + parsed_date = datetime.strptime(date[:10], '%Y-%m-%d') |
| 183 | + younger_than_x_days = (now - parsed_date).days <= PROJECT_MAX_AGE_DAYS |
| 184 | + (young_projects if younger_than_x_days else old_projects).append((name, date)) |
| 185 | + |
| 186 | + projects = sorted(young_projects + elixir_core_projects) + old_projects |
| 187 | + projects = [{'name': name, 'lastmod': lastmod} for (name, lastmod) in projects] |
| 188 | + sitemap_dict = {'projects': projects, 'etag': etag} |
| 189 | + save_json_file(cached_sitemap_json_path, sitemap_dict) |
| 190 | + |
| 191 | + return sitemap_dict |
| 192 | + |
| 193 | +def show_hexdocs_list(window, projects): |
| 194 | + """ Shows the hexdocs projects in a quick panel overlay. """ |
| 195 | + project_items = [ |
| 196 | + sublime.QuickPanelItem( |
| 197 | + trigger=project['name'], |
| 198 | + details=project['lastmod'] \ |
| 199 | + and 'Last modified: %s' % project['lastmod'][:-4].replace('T', ' ') \ |
| 200 | + or '', |
| 201 | + kind=sublime.KIND_NAVIGATION |
| 202 | + ) |
| 203 | + for project in projects |
| 204 | + ] |
| 205 | + |
| 206 | + def on_select(i): |
| 207 | + i >= 0 and webbrowser.open_new_tab(HEXDOCS_URL + '/' + projects[i]['name']) |
| 208 | + |
| 209 | + placeholder = 'Open a project\'s documentation in the web browser.' |
| 210 | + window.show_quick_panel(project_items, on_select, placeholder=placeholder) |
0 commit comments