diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 2c0cc5c..94e4762 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -24,15 +24,16 @@ CONTENTS *cp-contents* 16. Race .......................................................... |cp-race| 17. Credentials ............................................ |cp-credentials| 18. Submit ...................................................... |cp-submit| - 19. ANSI Colors ................................................... |cp-ansi| - 20. Highlight Groups ........................................ |cp-highlights| - 21. Terminal Colors .................................... |cp-terminal-colors| - 22. Highlight Customization .......................... |cp-highlight-custom| - 23. Helpers .................................................... |cp-helpers| - 24. Statusline Integration .................................. |cp-statusline| - 25. Panel Keymaps .......................................... |cp-panel-keys| - 26. File Structure ................................................ |cp-files| - 27. Health Check ................................................ |cp-health| + 19. Open ......................................................... |cp-open| + 20. ANSI Colors ................................................... |cp-ansi| + 21. Highlight Groups ........................................ |cp-highlights| + 22. Terminal Colors .................................... |cp-terminal-colors| + 23. Highlight Customization .......................... |cp-highlight-custom| + 24. Helpers .................................................... |cp-helpers| + 25. Statusline Integration .................................. |cp-statusline| + 26. Panel Keymaps .......................................... |cp-panel-keys| + 27. File Structure ................................................ |cp-files| + 28. Health Check ................................................ |cp-health| ============================================================================== INTRODUCTION *cp.nvim* @@ -487,6 +488,17 @@ COMMANDS *cp-commands* credentials are saved. --lang: Submit solution for a specific language. + :CP open [problem|contest|standings] + Open the URL for the current problem, contest, + or standings page in the browser via + |vim.ui.open|. Defaults to "problem" if no + argument is given. Warns if the URL is not + available (e.g. CSES has no standings). + Examples: > + :CP open + :CP open contest + :CP open standings +< State Restoration ~ :CP Restore state from current file. Automatically detects platform, contest, problem, @@ -580,6 +592,9 @@ through the same code path as |:CP|. *(cp-submit)* (cp-submit) Submit current solution. Equivalent to :CP submit. + *(cp-open)* +(cp-open) Open current problem URL in browser. Equivalent to :CP open. + *(cp-race-stop)* (cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop. @@ -1021,6 +1036,22 @@ Submit the current solution to the online judge. AtCoder Fully implemented. Others Not yet implemented. +============================================================================== +OPEN *cp-open* + +Open a platform URL for the current contest in the browser. + +:CP open [problem|contest|standings] + Open the URL for the active problem, contest page, or standings. + Defaults to "problem" if no argument is given. Uses |vim.ui.open|. + Warns if the URL is unavailable (e.g. CSES has no standings page). + + Platform support: + AtCoder problem, contest, standings + Codeforces problem, contest, standings + CSES problem, contest (no standings) + Others Not yet implemented. + ============================================================================== ANSI COLORS AND HIGHLIGHTING *cp-ansi* diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index eb82afa..3370532 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -10,6 +10,8 @@ ---@field name string ---@field display_name string ---@field url string +---@field contest_url string +---@field standings_url string ---@class ContestSummary ---@field display_name string @@ -148,12 +150,16 @@ end ---@param contest_id string ---@param problems Problem[] ---@param url string -function M.set_contest_data(platform, contest_id, problems, url) +---@param contest_url string +---@param standings_url string +function M.set_contest_data(platform, contest_id, problems, url, contest_url, standings_url) vim.validate({ platform = { platform, 'string' }, contest_id = { contest_id, 'string' }, problems = { problems, 'table' }, url = { url, 'string' }, + contest_url = { contest_url, 'string' }, + standings_url = { standings_url, 'string' }, }) cache_data[platform] = cache_data[platform] or {} @@ -165,6 +171,8 @@ function M.set_contest_data(platform, contest_id, problems, url) problems = problems, index_map = {}, url = url, + contest_url = contest_url, + standings_url = standings_url, } for i, p in ipairs(out.problems) do out.index_map[p.id] = i @@ -174,6 +182,25 @@ function M.set_contest_data(platform, contest_id, problems, url) M.save() end +---@param platform string? +---@param contest_id string? +---@param problem_id string? +---@return { problem: string|nil, contest: string|nil, standings: string|nil }|nil +function M.get_open_urls(platform, contest_id, problem_id) + if not platform or not contest_id then + return nil + end + if not cache_data[platform] or not cache_data[platform][contest_id] then + return nil + end + local cd = cache_data[platform][contest_id] + return { + problem = cd.url ~= '' and problem_id and string.format(cd.url, problem_id) or nil, + contest = cd.contest_url ~= '' and cd.contest_url or nil, + standings = cd.standings_url ~= '' and cd.standings_url or nil, + } +end + ---@param platform string ---@param contest_id string function M.clear_contest_data(platform, contest_id) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 5006cc9..6570748 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -245,6 +245,12 @@ local function parse_command(args) debug = debug, mode = mode, } + elseif first == 'open' then + local target = args[2] or 'problem' + if not vim.tbl_contains({ 'problem', 'contest', 'standings' }, target) then + return { type = 'error', message = 'Usage: :CP open [problem|contest|standings]' } + end + return { type = 'action', action = 'open', requires_context = true, subcommand = target } elseif first == 'pick' then local language = nil if #args >= 3 and args[2] == '--lang' then @@ -375,6 +381,20 @@ function M.handle_command(opts) require('cp.race').start(cmd.platform, cmd.contest, cmd.language) elseif cmd.action == 'race_stop' then require('cp.race').stop() + elseif cmd.action == 'open' then + local cache = require('cp.cache') + cache.load() + local urls = + cache.get_open_urls(state.get_platform(), state.get_contest_id(), state.get_problem_id()) + local url = urls and urls[cmd.subcommand] + if not url or url == '' then + logger.log( + ("No URL available for '%s'"):format(cmd.subcommand), + { level = vim.log.levels.WARN } + ) + return + end + vim.ui.open(url) elseif cmd.action == 'login' then require('cp.credentials').login(cmd.platform) elseif cmd.action == 'logout' then diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 21e8f62..b0dad5b 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,6 +13,7 @@ M.ACTIONS = { 'race', 'stress', 'submit', + 'open', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index a954d62..09b1f3b 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -243,7 +243,14 @@ function M.setup_contest(platform, contest_id, problem_id, language) contest_id, vim.schedule_wrap(function(result) local problems = result.problems or {} - cache.set_contest_data(platform, contest_id, problems, result.url) + cache.set_contest_data( + platform, + contest_id, + problems, + result.url, + result.contest_url or '', + result.standings_url or '' + ) local prov = state.get_provisional() if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then return diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 966940a..b25ea7e 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -606,6 +606,8 @@ class AtcoderScraper(BaseScraper): contest_id=contest_id, problems=problems, url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s", + contest_url=f"https://atcoder.jp/contests/{contest_id}", + standings_url=f"https://atcoder.jp/contests/{contest_id}/standings", ) except Exception as e: return self._metadata_error(str(e)) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 7a96483..d2e7083 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -223,6 +223,8 @@ class CodeforcesScraper(BaseScraper): contest_id=contest_id, problems=problems, url=f"https://codeforces.com/contest/{contest_id}/problem/%s", + contest_url=f"https://codeforces.com/contest/{contest_id}", + standings_url=f"https://codeforces.com/contest/{contest_id}/standings", ) except Exception as e: return self._metadata_error(str(e)) @@ -401,7 +403,8 @@ def _login_headless_cf(credentials: dict[str, str]) -> LoginResult: try: browser_cookies = session.context.cookies() - cookie_cache.write_text(json.dumps(browser_cookies)) + if any(c.get("name") == "X-User-Handle" for c in browser_cookies): + cookie_cache.write_text(json.dumps(browser_cookies)) except Exception: pass @@ -478,10 +481,7 @@ def _submit_headless( if "/enter" in page.url or "/login" in page.url: needs_relogin = True return - try: - _solve_turnstile(page) - except Exception: - pass + _solve_turnstile(page) try: page.select_option( 'select[name="submittedProblemIndex"]', @@ -550,7 +550,7 @@ def _submit_headless( try: browser_cookies = session.context.cookies() - if browser_cookies: + if any(c.get("name") == "X-User-Handle" for c in browser_cookies): cookie_cache.write_text(json.dumps(browser_cookies)) except Exception: pass diff --git a/scrapers/cses.py b/scrapers/cses.py index ef5deda..bd29af4 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -218,6 +218,8 @@ class CSESScraper(BaseScraper): contest_id=contest_id, problems=problems, url="https://cses.fi/problemset/task/%s", + contest_url="https://cses.fi/problemset", + standings_url="", ) async def scrape_contest_list(self) -> ContestListResult: @@ -352,8 +354,12 @@ class CSESScraper(BaseScraper): f"{API_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT ) api_data = api_resp.json() - token: str = api_data["X-Auth-Token"] - auth_url: str = api_data["authentication_url"] + token: str | None = api_data.get("X-Auth-Token") + auth_url: str | None = api_data.get("authentication_url") + if not token: + raise RuntimeError("CSES API login response missing 'X-Auth-Token'") + if not auth_url: + raise RuntimeError("CSES API login response missing 'authentication_url'") auth_page = await client.get(auth_url, headers=HEADERS, timeout=HTTP_TIMEOUT) auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text) @@ -388,8 +394,8 @@ class CSESScraper(BaseScraper): timeout=HTTP_TIMEOUT, ) return r.status_code == 200 - except Exception: - return False + except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError): + raise async def submit( self, diff --git a/scrapers/models.py b/scrapers/models.py index 4dafc64..2d579cb 100644 --- a/scrapers/models.py +++ b/scrapers/models.py @@ -42,6 +42,8 @@ class MetadataResult(ScrapingResult): contest_id: str = "" problems: list[ProblemSummary] = Field(default_factory=list) url: str + contest_url: str = "" + standings_url: str = "" model_config = ConfigDict(extra="forbid")