diff --git a/README.md b/README.md index 002923b0..adcc3477 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,8 @@ image_support = false, - `daily` opens the question of today - `list` opens a problem list picker +- +- `companies` opens a list of companies to filter questions by - `open` opens the current question in a default browser diff --git a/lua/leetcode-plugins/cn/queries.lua b/lua/leetcode-plugins/cn/queries.lua index f9d9d1f9..6b175e88 100644 --- a/lua/leetcode-plugins/cn/queries.lua +++ b/lua/leetcode-plugins/cn/queries.lua @@ -160,3 +160,13 @@ queries.session_progress = [[ } } ]] + +queries.streak = [[ + query questionCompanyTags { + companyTags { + name + slug + questionCount + } + } + ]] diff --git a/lua/leetcode-ui/group/page/problems.lua b/lua/leetcode-ui/group/page/problems.lua index 8e0cfcdc..ab5cfa98 100644 --- a/lua/leetcode-ui/group/page/problems.lua +++ b/lua/leetcode-ui/group/page/problems.lua @@ -1,4 +1,5 @@ local cmd = require("leetcode.command") +local config = require("leetcode.config") local Title = require("leetcode-ui.lines.title") local Button = require("leetcode-ui.lines.button.menu") @@ -33,14 +34,21 @@ local daily = Button("Daily", { on_press = cmd.qot, }) +local companies = Button("Companies", { + icon = "", + sc = "c", + on_press = cmd.companies, +}) + local back = BackButton("menu") -page:insert(Buttons({ - list, - random, - daily, - back, -})) +button_list = { list, random, daily } +if config.auth.is_premium then + table.insert(button_list, companies) +end +table.insert(button_list, back) + +page:insert(Buttons(button_list)) page:insert(footer) diff --git a/lua/leetcode/api/companies.lua b/lua/leetcode/api/companies.lua new file mode 100644 index 00000000..53495016 --- /dev/null +++ b/lua/leetcode/api/companies.lua @@ -0,0 +1,81 @@ +local utils = require("leetcode.api.utils") +local config = require("leetcode.config") +local log = require("leetcode.logger") +local queries = require("leetcode.api.queries") +local urls = require("leetcode.api.urls") +local Spinner = require("leetcode.logger.spinner") + +---@class lc.CompaniesApi +local Companies = {} + +---@param cb? fun(res: lc.cache.Company[]|nil, err: lc.err) +---@param noti? boolean +-- +---@return lc.cache.Company[] lc.err +function Companies.all(cb, noti) + local query = queries.companies + + local spinner + if noti then + spinner = Spinner:init("updating cache...", "points") + end + if cb then + utils.query(query, _, { + endpoint = urls.companies, + callback = function(res, err) + if err then + if spinner then + spinner:stop(err.msg, false) + end + return cb(nil, err) + end + local data = res.data + local companies = data["companyTags"] + if spinner then + spinner:stop("cache updated") + end + cb(companies) + end, + }) + else + local res, err = utils.query(query) + if err then + if spinner then + spinner:stop(err.msg, false) + end + return nil, err + else + local data = res.data + local companies = data["companyTags"] + if spinner then + spinner:stop("cache updated") + end + return companies + end + end +end + +function Companies.problems(company, cb) + local url = urls.company_problems:format(company) + + if cb then + utils.get(url, { + callback = function(res, err) + if err then + return cb(nil, err) + end + local questions = res["questions"] + cb(questions) + end + }) + else + local res, err = utils.get(url) + if err then + return nil, err + end + local questions = res.data["questions"] + return questions + end +end + +return Companies diff --git a/lua/leetcode/api/queries.lua b/lua/leetcode/api/queries.lua index b8356208..836a4b7e 100644 --- a/lua/leetcode/api/queries.lua +++ b/lua/leetcode/api/queries.lua @@ -179,4 +179,14 @@ queries.session_progress = [[ } ]] +queries.companies = [[ + query questionCompanyTags { + companyTags { + name + slug + questionCount + } + } + ]] + return queries diff --git a/lua/leetcode/api/types.lua b/lua/leetcode/api/types.lua index 7cd2f84d..fc6f0f1d 100644 --- a/lua/leetcode/api/types.lua +++ b/lua/leetcode/api/types.lua @@ -359,6 +359,12 @@ ---@field questions_count table ---@field submit_stats lc.Stats.SubmissionStat +-------------------------------------------- +--- Companies +-------------------------------------------- +---@class lc.Companies.Res +---@field companyTags {name: string, slug: string, questionCount: number}[] + -------------------------------------------- --- Skills -------------------------------------------- diff --git a/lua/leetcode/api/urls.lua b/lua/leetcode/api/urls.lua index ea7de617..6d558aa4 100644 --- a/lua/leetcode/api/urls.lua +++ b/lua/leetcode/api/urls.lua @@ -5,10 +5,12 @@ urls.base = "/graphql/" urls.solved = "/graphql/" urls.calendar = "/graphql/" urls.languages = "/graphql/" +urls.companies = "/graphql/" urls.skills = "/graphql/" urls.auth = "/graphql/" urls.problems = "/api/problems/%s/" +urls.company_problems = "/problems/tag-data/company-tags/%s/" urls.interpret = "/problems/%s/interpret_solution/" urls.submit = "/problems/%s/submit/" urls.run = "/problems/%s/interpret_solution/" diff --git a/lua/leetcode/api/utils.lua b/lua/leetcode/api/utils.lua index 01210743..710879fd 100644 --- a/lua/leetcode/api/utils.lua +++ b/lua/leetcode/api/utils.lua @@ -248,4 +248,27 @@ function utils.translate_titles(problems, titles) end, problems) end +--@return lc.cache.Question[] +function utils.normalize_company_problems(problems) + return vim.tbl_map(function(p) + return { + status = p.status, + id = tonumber(p.questionId), + frontend_id = tonumber(p.questionFrontendId), + title = p.title, + title_cn = "", + title_slug = p.titleSlug, + link = ("https://leetcode.%s/problems/%s/"):format( + config.domain, + p.titleSlug + ), + paid_only = p.isPaidOnly, + ac_rate = tonumber(p.acRate:sub(1, -2)), + difficulty = p.difficulty, + starred = false, + topic_tags = {}, + } + end, problems) +end + return utils diff --git a/lua/leetcode/cache/companylist.lua b/lua/leetcode/cache/companylist.lua new file mode 100644 index 00000000..492a9dd7 --- /dev/null +++ b/lua/leetcode/cache/companylist.lua @@ -0,0 +1,123 @@ +local path = require("plenary.path") +local companies_api = require("leetcode.api.companies") + +local log = require("leetcode.logger") +local config = require("leetcode.config") +local interval = config.user.cache.update_interval + +---@type Path +local file = config.storage.cache:joinpath(("companylist%s"):format(config.is_cn and "_cn" or "")) + +---@type { at: integer, payload: lc.cache.payload } +local hist = nil + +---@class lc.cache.Company +---@field name string +---@field slug string +---@field questionCount number + + +---@class lc.cache.Copmanylist +local Companylist = {} + +---@return lc.cache.Company[] +function Companylist.get() + return Companylist.read().data +end + +---@return lc.cache.payload +function Companylist.read() + if not file:exists() then + return Companylist.populate() + end + + local time = os.time() + if hist and (time - hist.at) <= math.min(60, interval) then + return hist.payload + end + + local contents = file:read() + if not contents or type(contents) ~= "string" then + return Companylist.populate() + end + + local cached = Companylist.parse(contents) + + if not cached or (cached.version ~= config.version or cached.username ~= config.auth.name) then + return Companylist.populate() + end + + hist = { at = time, payload = cached } + if (time - cached.updated_at) > interval then + Companylist.update() + end + + return cached +end + +---@return lc.cache.payload +function Companylist.populate() + local res, err = companies_api.all(nil, true) + + if not res or err then + local msg = (err or {}).msg or "failed to fetch company list" + error(msg) + end + + Companylist.write({ data = res }) + return hist.payload +end + +function Companylist.update() + companies_api.all(function(res, err) + if not err then + Companylist.write({ data = res }) + end + end, true) +end + +---@return lc.cache.Company +function Companylist.get_by_title_slug(title_slug) + local companies = Companylist.get() + + local company = vim.tbl_filter(function(e) + return e.title_slug == slug + end, companies)[1] + + assert(company("Company `%s` not found. Try updating cache?"):format(title_slug)) + return company +end + +---@param payload? lc.cache.payload +function Companylist.write(payload) + payload = vim.tbl_deep_extend("force", { + version = config.version, + updated_at = os.time(), + username = config.auth.name, + }, payload) + + if not payload.data then + payload.data = Companylist.get() + end + + file:write(vim.json.encode(payload), "w") + hist = { at = os.time(), payload = payload } +end + +---@alias lc.cache.payload { version: string, data: lc.cache.Company[], updated_at: integer, username: string } + +---@param str string +--- +---@return lc.cache.payload +function Companylist.parse(str) + return vim.json.decode(str) +end + +function Companylist.delete() + if not file:exists() then + return false + end + return pcall(path.rm, file) +end + +return Companylist diff --git a/lua/leetcode/cache/init.lua b/lua/leetcode/cache/init.lua index b7052a10..12b4e53e 100644 --- a/lua/leetcode/cache/init.lua +++ b/lua/leetcode/cache/init.lua @@ -1,10 +1,15 @@ +local config = require("leetcode.config") local Problemlist = require("leetcode.cache.problemlist") +local Companylist = require("leetcode.cache.companylist") ---@class lc.Cache local cache = {} function cache.update() Problemlist.update() + if config.auth.is_premium then + Companylist.update() + end end return cache diff --git a/lua/leetcode/command/init.lua b/lua/leetcode/command/init.lua index fd56ae71..444ee48a 100644 --- a/lua/leetcode/command/init.lua +++ b/lua/leetcode/command/init.lua @@ -29,6 +29,19 @@ function cmd.problems(options) require("leetcode.pickers.question").pick(p, options) end +---@param options table +function cmd.companies(options) + require("leetcode.utils").auth_guard() + if not config.auth.is_premium then + err.msg = "Selecting problems by company is only for premium." + err.lvl = vim.log.levels.WARN + return nil, err + end + + local c = require("leetcode.cache.companylist").get() + require("leetcode.pickers.company").pick(c, options) +end + ---@param cb? function function cmd.cookie_prompt(cb) local cookie = require("leetcode.cache.cookie") @@ -635,6 +648,10 @@ cmd.commands = { cmd.problems, _args = arguments.list, }, + companies = { + cmd.companies, + _args = arguments.list, + }, random = { cmd.random_question, _args = arguments.random, diff --git a/lua/leetcode/pickers/company.lua b/lua/leetcode/pickers/company.lua new file mode 100644 index 00000000..a6c9c854 --- /dev/null +++ b/lua/leetcode/pickers/company.lua @@ -0,0 +1,102 @@ +local log = require("leetcode.logger") +local t = require("leetcode.translator") +local utils = require("leetcode.api.utils") +local companies_api = require("leetcode.api.companies") + + +local pickers = require("telescope.pickers") +local finders = require("telescope.finders") +local conf = require("telescope.config").values +local config = require("leetcode.config") + +local entry_display = require("telescope.pickers.entry_display") +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") + +---@param company lc.cache.Company +--- +---@return string +local function company_formatter(company) + return ("%s"):format( + company.name + ) +end + + +---@param company lc.cache.Company +local function display_company(company) + local question_count = { company.questionCount, "leetcode_ref" } + local name = { company.name } + return unpack({ name, question_count }) +end + +local displayer = entry_display.create({ + separator = " ", + items = { + { width = 90 }, + { width = 5 }, + }, +}) + +local function make_display(entry) + ---@type lc.cache.Company + local c = entry.value + + return displayer({ + display_company(c), + }) +end + +local function entry_maker(entry) + return { + value = entry, + display = make_display, + ordinal = company_formatter(entry), + } +end + +local theme = require("telescope.themes").get_dropdown({ + layout_config = { + width = 100, + height = 20, + }, +}) + + +return { + ---@param companies lc.cache.Company[] + pick = function(companies, options) + pickers + .new(theme, { + prompt_title = t("Select a Company"), + finder = finders.new_table({ + results = companies, + entry_maker = entry_maker, + }), + sorter = conf.generic_sorter(theme), + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + if not selection then + return + end + + local c = selection.value + actions.close(prompt_bufnr) + local p, err = companies_api.problems(c.slug, function(res, err) + if err then + return log.error(err.msg) + end + local problems = utils.normalize_company_problems(res) + require("leetcode.pickers.question").pick(problems, options) + end) + end) + + return true + end, + }) + :find() + end, +} +--- +---