From eea2d6c3df95fb0d4899776ed41f4d459cc2db59 Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Wed, 26 Mar 2025 13:51:49 +0000 Subject: [PATCH] chore: various updates --- api/.gitignore | 3 + ...erties => application.properties.template} | 3 + api/build.gradle.kts | 7 +- api/gradle/gradle-daemon-jvm.properties | 2 + .../applications/ApplicationsApplication.kt | 5 +- .../applications/ApplicationsController.kt | 194 +++--- .../applications/Configuration.kt | 88 ++- .../com/andr3h3nriqu3s/applications/Flair.kt | 27 + .../com/andr3h3nriqu3s/applications/Mail.kt | 391 ++++++++++++ .../com/andr3h3nriqu3s/applications/User.kt | 39 +- api/src/main/resources/schema.sql | 1 + extensions/background-script.js | 1 - extensions/definitions.js | 11 +- site/src/app.css | 118 ++-- site/src/lib/ApplicationsStore.svelte.ts | 1 + site/src/routes/ApplicationsList.svelte | 52 +- site/src/routes/graphs/+page.svelte | 555 ++++++++++++++++++ site/src/routes/graphs/Pie.svelte | 172 ++++++ site/src/routes/work-area/AutoDropZone.svelte | 4 +- site/src/routes/work-area/DropZone.svelte | 7 +- .../routes/work-area/LinkApplication.svelte | 11 +- site/src/routes/work-area/NewUrlDialog.svelte | 13 +- site/src/routes/work-area/Timeline.svelte | 18 +- site/src/routes/work-area/WorkArea.svelte | 40 +- 24 files changed, 1527 insertions(+), 236 deletions(-) rename api/{application.properties => application.properties.template} (91%) create mode 100644 api/gradle/gradle-daemon-jvm.properties create mode 100644 api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt create mode 100644 site/src/routes/graphs/+page.svelte create mode 100644 site/src/routes/graphs/Pie.svelte diff --git a/api/.gitignore b/api/.gitignore index 5a979af..175f6da 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### secrets ### +application.properties diff --git a/api/application.properties b/api/application.properties.template similarity index 91% rename from api/application.properties rename to api/application.properties.template index 99d3969..02561eb 100644 --- a/api/application.properties +++ b/api/application.properties.template @@ -5,6 +5,9 @@ spring.datasource.password=applications spring-boot.run.jvmArguments=-Duser.timezone=UTC spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +mail.token.api="TODO" +mail.local.user="TODO" + # Disable the trace on the error responses server.error.include-stacktrace=never diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 19bed78..dbf1fcc 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -19,17 +19,20 @@ dependencies { implementation("org.springframework.security:spring-security-crypto:6.0.3") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-mustache") + implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.hibernate.orm:hibernate-community-dialects") + implementation("org.json:json:20250107") + implementation("org.bouncycastle:bcprov-jdk18on:1.76") + developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - implementation("org.bouncycastle:bcprov-jdk18on:1.76") } springBoot { mainClass.set("com.andr3h3nriqu3s.applications.ApplicationsApplicationKt") } @@ -41,5 +44,3 @@ kotlin { } tasks.withType { useJUnitPlatform() } - -tasks.withType { useJUnitPlatform() } diff --git a/api/gradle/gradle-daemon-jvm.properties b/api/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..858feb7 --- /dev/null +++ b/api/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=17 diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsApplication.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsApplication.kt index 49b286a..cbc957f 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsApplication.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsApplication.kt @@ -2,11 +2,10 @@ package com.andr3h3nriqu3s.applications import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling -@SpringBootApplication -class ApplicationsApplication +@SpringBootApplication @EnableScheduling class ApplicationsApplication fun main(args: Array) { runApplication(*args) } - diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt index e2d1f39..ac59d5f 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt @@ -49,6 +49,7 @@ data class Application( var message: String, var create_time: String, var simple_url: String, + var job_level: String, var flairs: List, var events: List, ) { @@ -68,6 +69,7 @@ data class Application( rs.getString("message"), rs.getString("create_time"), rs.getString("simple_url"), + rs.getString("job_level"), emptyList(), emptyList(), ) @@ -113,7 +115,7 @@ class ApplicationsController( eventService.create(application.id, EventType.View) } - val flairs = application.flairs.map { it.toFlairSimple() } + val flairs = application.flairs.filter { it.sort != -1 }.map { it.toFlairSimple() } return CVData( application.company, @@ -147,6 +149,7 @@ class ApplicationsController( "", "", "", + "", emptyList(), emptyList(), ) @@ -198,92 +201,7 @@ class ApplicationsController( @RequestHeader("token") token: String ): List { val user = sessionService.verifyTokenThrow(token) - - var text = submit.text.replace("=\n", "") - - var urls: List = emptyList() - - while (true) { - var index = text.indexOf("href") - - if (index == -1) { - break - } - - var new_url = StringBuilder() - - var found_start = false - - while (true) { - if (found_start) { - if (text[index] == '"') { - break - } - new_url.append(text[index]) - } else if (text[index] == '"') { - found_start = true - } - index++ - } - - text = text.substring(index) - - urls = urls.plus(new_url.toString().replace("&", "&").replace("=3D", "=")) - } - - print("found: ") - print(urls.size) - print(" links\n") - - // jobListing is for glassdoor urls - // jobs/view is for linkeding urls - urls = - urls.filter { predicate -> - print("url: ") - print(predicate) - print("\n") - predicate.contains("jobListing") || predicate.contains("jobs/view") - } - - print("found fileted: ") - print(urls.size) - print(" links\n") - - urls = urls.toSet().toList() - - print("removed duplicates: ") - print(urls.size) - print(" links\n") - - var applications = - urls.map { elm -> - Application( - UUID.randomUUID().toString(), - if (elm.contains("linkedin")) elm.split("?")[0] else elm, - "New Application", - user.id, - "", - "", - null, - "", - "", - false, - "", - "", - if (elm.contains("linkedin")) elm.split("?")[0] else "", - emptyList(), - emptyList(), - ) - } - - applications = - applications.filter { elm -> applicationService.createApplication(user, elm) } - - print("created new: ") - print(applications.size) - print(" links\n") - - return applications + return applicationService.parseText(user, submit.text) } @GetMapping(path = ["/list"], produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -509,6 +427,94 @@ class ApplicationService( db.update("insert into applications_urls (application_id, url) values (?, ?);", id, url) } + public fun parseText(user: UserDb, textIn: String): List { + var text = textIn.replace("=\n", "") + + var urls: List = emptyList() + + while (true) { + var index = text.indexOf("href") + + if (index == -1) { + break + } + + var new_url = StringBuilder() + + var found_start = false + + while (true) { + if (found_start) { + if (text[index] == '"') { + break + } + new_url.append(text[index]) + } else if (text[index] == '"') { + found_start = true + } + index++ + } + + text = text.substring(index) + + urls = urls.plus(new_url.toString().replace("&", "&").replace("=3D", "=")) + } + + print("found: ") + print(urls.size) + print(" links\n") + + // jobListing is for glassdoor urls + // jobs/view is for linkeding urls + urls = + urls.filter { predicate -> + print("url: ") + print(predicate) + print("\n") + predicate.contains("jobListing") || predicate.contains("jobs/view") + } + + print("found fileted: ") + print(urls.size) + print(" links\n") + + urls = urls.toSet().toList() + + print("removed duplicates: ") + print(urls.size) + print(" links\n") + + var applications = + urls.map { elm -> + Application( + UUID.randomUUID().toString(), + if (elm.contains("linkedin")) elm.split("?")[0] else elm, + "New Application", + user.id, + "", + "", + null, + "", + "", + false, + "", + "", + if (elm.contains("linkedin")) elm.split("?")[0] else "", + "", + emptyList(), + emptyList(), + ) + } + + applications = applications.filter { elm -> this.createApplication(user, elm) } + + print("created new: ") + print(applications.size) + print(" links\n") + + return applications + } + public fun createApplication(user: UserDb, application: Application): Boolean { if (this.findApplicationByUrl(user, application.url) != null) { return false @@ -516,7 +522,7 @@ class ApplicationService( // Create time is auto created by the database db.update( - "insert into applications (id, url, title, user_id, extra_data, payrange, status_id, company, recruiter, message, agency, simple_url) values (?,?,?,?,?,?,?,?,?,?,?,?);", + "insert into applications (id, url, title, user_id, extra_data, payrange, status_id, company, recruiter, message, agency, simple_url, job_level) values (?,?,?,?,?,?,?,?,?,?,?,?,?);", application.id, application.url, application.title, @@ -529,6 +535,7 @@ class ApplicationService( application.message, application.agency, application.simple_url, + application.job_level, ) eventService.create(application.id, EventType.Creation) @@ -576,7 +583,7 @@ class ApplicationService( public fun update(application: Application): Application { // I don't want ot update create_time db.update( - "update applications set url=?, title=?, user_id=?, extra_data=?, payrange=?, company=?, recruiter=?, message=?, agency=?, simple_url=? where id=?", + "update applications set url=?, title=?, user_id=?, extra_data=?, payrange=?, company=?, recruiter=?, message=?, agency=?, simple_url=?, job_level=? where id=?", application.url, application.title, application.user_id, @@ -587,6 +594,7 @@ class ApplicationService( application.message, application.agency, application.simple_url, + application.job_level, application.id, ) return application @@ -609,5 +617,13 @@ class ApplicationService( "delete from applications_urls where application_id=?", application.id, ) + db.update( + "delete from events where application_id=?", + application.id, + ) + db.update( + "delete from flair_link where application_id=?", + application.id, + ) } } diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Configuration.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Configuration.kt index 91488ca..7e5f301 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Configuration.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Configuration.kt @@ -1,71 +1,65 @@ package com.andr3h3nriqu3s.appliations -import org.springframework.context.annotation.Configuration -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import org.springframework.web.servlet.config.annotation.EnableWebMvc -import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer -import org.springframework.web.servlet.config.annotation.CorsRegistry -import org.springframework.web.servlet.config.annotation.PathMatchConfigurer -import org.springframework.web.servlet.mvc.Controller -import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver -import org.springframework.web.method.HandlerTypePredicate -import org.springframework.web.bind.annotation.RestController -import org.springframework.http.MediaType -import org.springframework.stereotype.Component -import org.springframework.web.servlet.ModelAndView - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse - +import com.andr3h3nriqu3s.applications.BadRequest import com.andr3h3nriqu3s.applications.NoToken import com.andr3h3nriqu3s.applications.NotAuth -import com.andr3h3nriqu3s.applications.UserNotFound import com.andr3h3nriqu3s.applications.NotFound -import com.andr3h3nriqu3s.applications.BadRequest +import com.andr3h3nriqu3s.applications.UserNotFound +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.stereotype.Component +import org.springframework.web.servlet.ModelAndView +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver @Configuration +@EnableScheduling @EnableWebMvc class WebConfig : WebMvcConfigurer { - override public fun configureContentNegotiation(configurer: ContentNegotiationConfigurer){ + override public fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) { // configurer.defaultContentType( MediaType.APPLICATION_JSON ); configurer.mediaType("json", MediaType.APPLICATION_JSON) } override public fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("*").allowedOrigins("*") + registry.addMapping("*").allowedOrigins("*") } - } @Component public class RestResponseStatusExceptionResolver : AbstractHandlerExceptionResolver() { - override protected fun doResolveException( - requeset: HttpServletRequest, - response: HttpServletResponse, - handler: Any?, - ex: Exception - ): ModelAndView? { - try { - if (ex is NoToken || ex is NotAuth || ex is UserNotFound) { - response.sendError(HttpServletResponse.SC_FORBIDDEN) - return ModelAndView() - } - if (ex is NotFound) { - response.sendError(HttpServletResponse.SC_NOT_FOUND) - return ModelAndView() - } - if (ex is BadRequest) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST) - return ModelAndView() - } - } catch (handlerException: Exception) { - print("Faield to handle exception ") - print(handlerException) + override protected fun doResolveException( + requeset: HttpServletRequest, + response: HttpServletResponse, + handler: Any?, + ex: Exception + ): ModelAndView? { + try { + if (ex is NoToken || ex is NotAuth || ex is UserNotFound) { + response.sendError(HttpServletResponse.SC_FORBIDDEN) + return ModelAndView() + } + if (ex is NotFound) { + response.sendError(HttpServletResponse.SC_NOT_FOUND) + return ModelAndView() + } + if (ex is BadRequest) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST) + return ModelAndView() + } + } catch (handlerException: Exception) { + print("Faield to handle exception ") + print(handlerException) print("\n") } - return null; - } - + return null + } } diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Flair.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Flair.kt index 9f54624..4a50633 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Flair.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Flair.kt @@ -63,6 +63,12 @@ class FlairController( return flairService.listUser(user) } + @GetMapping(path = ["/stats"], produces = [MediaType.APPLICATION_JSON_VALUE]) + public fun stats(@RequestHeader("token") token: String): List { + val user = sessionService.verifyTokenThrow(token) + return flairService.listStat(user) + } + @GetMapping(path = ["/simple/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) public fun listSimple(@PathVariable id: String): List { var application = applicationService.findApplicationByIdNoUser(id) @@ -88,6 +94,19 @@ data class SimpleFlair( val showFullDescription: Int, ) +data class FlairStatItem(var id: String, var color: String, var name: String, var count: Int) { + companion object : RowMapper { + override public fun mapRow(rs: ResultSet, rowNum: Int): FlairStatItem { + return FlairStatItem( + rs.getString("id"), + rs.getString("color"), + rs.getString("name"), + rs.getInt("count"), + ) + } + } +} + data class Flair( var id: String, var user_id: String, @@ -182,6 +201,14 @@ public class FlairService(val db: JdbcTemplate) { ) .toList() + public fun listStat(user: UserDb): List = + db.query( + "select f.id as id, f.color as color, f.name as name, count(*) as count from flair as f inner join flair_link as fl on f.id=fl.flair_id where user_id=? group by f.id", + arrayOf(user.id), + FlairStatItem + ) + .toList() + public fun listUserId(id: String): List = db.query( "select * from flair where user_id=? and description != '' order by name asc;", diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt new file mode 100644 index 0000000..edd4e2d --- /dev/null +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt @@ -0,0 +1,391 @@ +package com.andr3h3nriqu3s.applications + +import java.time.LocalDate +import kotlin.io.println +import org.json.JSONArray +import org.json.JSONObject +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.function.client.ExchangeStrategies +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@RestController +@ControllerAdvice +@RequestMapping("/api/mail") +@kotlin.io.encoding.ExperimentalEncodingApi +class MailController( + val userService: UserService, + val applicationService: ApplicationService, + val sessionService: SessionService, +) { + + @Value("\${mail.token.api}") private val ApiToken: String? = null + @Value("\${mail.local.user}") private val TargetUserUUID: String? = null + + private var failed = false + private var user: UserDb? = null + var value = 0 + var account: String? = null + var client: WebClient? = null + var mailbox: String? = null + + @Scheduled(initialDelay = 15000, fixedDelay = 10 * 60 * 1000) + public fun getLastEmail(): List { + if (failed) { + error("Get Last Email marked failed skiping") + } + if (TargetUserUUID == null || TargetUserUUID == "") { + error("Target User not defined") + } + if (user == null) { + user = userService.findUserById(TargetUserUUID) + if (user == null) { + failed = true + error("User ${TargetUserUUID} not found") + } + } + if (client == null) { + val size = 16 * 1024 * 1024 + val strategies = + ExchangeStrategies.builder() + .codecs { it.defaultCodecs().maxInMemorySize(size) } + .build() + client = + WebClient.builder() + .exchangeStrategies(strategies) + .baseUrl("https://api.fastmail.com") + .defaultHeader("Authorization", "Bearer ${this.ApiToken}") + .defaultHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE + ) + .build() + } + if (account == null) { + println("getting account") + var session_data_r = + client!!.get().uri("jmap/session").retrieve().bodyToMono().block()!! + var session_data = JSONObject(session_data_r) + account = session_data.getJSONObject("accounts").keys().next() + } + if (mailbox == null) { + val mailbox_r = + JMapQueryBuilder("Mailbox/query", "mailboxes", account!!) + .filter("name", "Inbox") + println("getting mailbox ${mailbox_r}") + var mailbox_r_data = + JSONObject( + client!!.post() + .uri("jmap/api") + .bodyValue(mailbox_r.toString()) + .retrieve() + .bodyToMono() + .block()!! + ) + mailbox = + mailbox_r_data + .getJSONArray("methodResponses") + .getJSONArray(0) + .getJSONObject(1) + .getJSONArray("ids") + .getString(0) + println("mailbox got ${mailbox}") + } + + println("\n\nGet Email @ ${LocalDate.now()}\n\n") + val email_get_obj = + JMapQueryBuilder("Email/query", "list_emails", account!!) + .arg("limit", 1) + .arg("position", 0) + .withOperator("AND") + .filter( + "_", + JMapQueryBuilder() + .filter("inMailbox", mailbox!!) + .filter("notKeyword", "\$seen") + .toFilters() + ) + .filter( + "_", + JMapQueryBuilder() + .withOperator("OR") + .filter("from", "noreply@glassdoor.com") + .filter("from", "jobalerts-noreply@linkedin.com") + .filter("from", "jobs-listings@linkedin.com") + .toFilters() + ) + .sort("receivedAt", true) + .newQuery("Email/get", "email") + .arg("bodyProperties", arrayOf("partId", "blobId", "size", "type")) + .arg( + "properties", + arrayOf( + "id", + "from", + "cc", + "subject", + "htmlBody", + "textBody", + "bodyValues", + "keywords" + ) + ) + .arg("fetchTextBodyValues", true) + .arg("fetchHTMLBodyValues", true) + .argFromPrev("#ids", "list_emails", "Email/query", "/ids") + .print() + + val email_data_r = + client!!.post() + .uri("jmap/api") + .bodyValue(email_get_obj.toString()) + .exchangeToMono { + if (it.statusCode().value().equals(200)) { + it.bodyToMono() + } else { + println("here ${it.statusCode()} ${it.statusCode().value()}") + Mono.just("asdfasdf") + } + } + .block()!! + // .bodyToMono() + + val email_data = JSONObject(email_data_r) + + println("GOT email data") + + val email_r = + (email_data + .getJSONArray("methodResponses") + .filter { (it as JSONArray).getString(0) == "Email/get" } + .get(0) as + JSONArray) + .getJSONObject(1) + + if (email_r.getJSONArray("list").length() == 0) { + println("No emails found") + return arrayListOf() + } + + val email_html_body = email_r.getJSONArray("list").getJSONObject(0).getJSONArray("htmlBody") + + if (email_html_body.length() != 1) { + error("Not sure how to handle non 1 sized html blobs") + } + + val email_html_body_id = email_html_body.getJSONObject(0).getString("partId") + + val email_text_obj = + email_r.getJSONArray("list") + .getJSONObject(0) + .getJSONObject("bodyValues") + .getJSONObject(email_html_body_id) + .getString("value") + + var applications: List = arrayListOf() + + if (!email_text_obj.contains("Don't forget to apply to these jobs") && + !email_text_obj.contains("Your recently viewed jobs") + ) { + println("\n\nAdding Emails\n\n") + applications = applicationService.parseText(user!!, email_text_obj) + println("\n\nUdating email\n\n") + } else { + println("Its one of those awfull glassdoor emails") + } + + val email_id = email_r.getJSONArray("list").getJSONObject(0).getString("id") + + val update_email = + JMapQueryBuilder("Email/set", "set", account!!) + .updateNewItem(email_id) + .update("keywords/\$seen", true) + .print() + + client!!.post() + .uri("jmap/api") + .bodyValue(update_email.toString()) + .retrieve() + .bodyToMono() + .block()!! + + return applications + } + + @GetMapping(path = ["/getNext"], produces = [MediaType.APPLICATION_JSON_VALUE]) + public fun getCV(@RequestHeader("token") token: String): List { + sessionService.verifyTokenThrow(token) + return getLastEmail() + } +} + +class JMapQueryBuilder { + var query: String + var id: String + val accountId: String + + private var queries: JSONArray? = null + private var _filters: JSONArray? = null + private var filters: JSONObject? = null + private var args: JSONObject = JSONObject() + private var sorts: JSONArray? = null + + private var update: JSONObject? = null + private var update_cur: JSONObject? = null + + constructor(query: String, id: String, accountId: String) { + this.query = query + this.id = id + this.accountId = accountId + } + + constructor() { + this.query = "" + this.id = "" + this.accountId = "" + } + + fun updateNewItem(id: String): JMapQueryBuilder { + if (update == null) { + update = JSONObject() + } + update_cur = JSONObject() + update?.put(id, update_cur) + return this + } + + // updateNewItem MUST be called before this + fun update(key: String, value: Any): JMapQueryBuilder { + update_cur?.put(key, value) + return this + } + + fun newQuery(query: String, id: String): JMapQueryBuilder { + if (queries == null) { + queries = JSONArray() + } + queries?.put(toQueryOBJ()) + _filters = null + filters = null + args = JSONObject() + sorts = null + this.query = query + this.id = id + return this + } + + fun withOperator(operator: String): JMapQueryBuilder { + filters = JSONObject() + filters?.put("operator", operator) + _filters = JSONArray() + filters?.put("conditions", _filters) + return this + } + + fun toFilters(): JSONObject { + return this.filters!! + } + + fun filter(key: String, value: Any): JMapQueryBuilder { + if (this._filters != null) { + if (key == "_") { + this._filters?.put(value) + } else { + var obj = JSONObject() + obj.put(key, value) + this._filters?.put(obj) + } + return this + } + if (this.filters == null) { + this.filters = JSONObject() + } + this.filters?.put(key, value) + return this + } + + fun print(): JMapQueryBuilder { + println(this.toQuery()) + return this + } + + fun arg(key: String, value: Any): JMapQueryBuilder { + this.args.put(key, value) + return this + } + + fun argFromPrev( + key: String, + prevName: String, + prevQuery: String, + path: String + ): JMapQueryBuilder { + var obj = JSONObject() + obj.put("resultOf", prevName) + obj.put("name", prevQuery) + obj.put("path", path) + this.args.put(key, obj) + return this + } + + fun sort(key: String, isAscending: Boolean): JMapQueryBuilder { + if (this.sorts == null) { + this.sorts = JSONArray() + } + var obj = JSONObject() + obj.put("property", key) + obj.put("isAscending", isAscending) + this.sorts?.put(obj) + return this + } + + fun toQueryOBJ(): JSONArray { + var query = JSONArray() + + args.put("accountId", accountId) + if (filters != null) { + args.put("filter", filters) + } + + if (sorts != null) { + args.put("sort", sorts) + } + + if (update != null) { + args.put("update", update) + } + + query.put(this.query) + query.put(args) + query.put(id) + + return query + } + + fun toQuery(): String { + var rj = JSONObject() + rj.put("using", arrayListOf("urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail")) + + if (queries != null) { + queries?.put(toQueryOBJ()) + rj.put("methodCalls", queries) + } else { + rj.put("methodCalls", arrayOf(toQueryOBJ())) + } + + return rj.toString() + } + + override fun toString(): String { + return toQuery() + } +} diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/User.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/User.kt index e109698..af472e0 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/User.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/User.kt @@ -1,10 +1,12 @@ package com.andr3h3nriqu3s.applications +import java.sql.ResultSet import java.util.UUID import kotlin.io.encoding.Base64 import kotlin.random.Random import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper import org.springframework.security.crypto.argon2.Argon2PasswordEncoder import org.springframework.stereotype.Service import org.springframework.web.bind.annotation.ControllerAdvice @@ -40,6 +42,18 @@ data class UserDb( throw NotAuth() } } + + companion object : RowMapper { + override public fun mapRow(rs: ResultSet, rowNum: Int): UserDb { + return UserDb( + rs.getString("id"), + rs.getString("username"), + rs.getString("email"), + rs.getString("passwd"), + rs.getInt("level") + ) + } + } } data class UserCreateRequest(val email: String, val password: String, val username: String) @@ -102,18 +116,17 @@ class UserService(val db: JdbcTemplate) { } fun findUserByEmail(email: String): UserDb? { - var users = - db - .query("select * from users where email=?", arrayOf(email)) { response, _ -> - UserDb( - response.getString("id"), - response.getString("username"), - response.getString("email"), - response.getString("passwd"), - response.getInt("level") - ) - } - .toList() + var users = db.query("select * from users where email=?", arrayOf(email), UserDb).toList() + + if (users.size == 0) { + return null + } + + return users[0] + } + + fun findUserById(id: String): UserDb? { + var users = db.query("select * from users where id=?", arrayOf(id), UserDb).toList() if (users.size == 0) { return null @@ -166,7 +179,7 @@ class SessionService(val db: JdbcTemplate) { fun verifyToken(token: String?): UserDb? { if (token == null) { - return null; + return null } var users = db diff --git a/api/src/main/resources/schema.sql b/api/src/main/resources/schema.sql index 15a4c4c..02491b5 100644 --- a/api/src/main/resources/schema.sql +++ b/api/src/main/resources/schema.sql @@ -18,6 +18,7 @@ create table if not exists applications ( mesasge text default '', user_id text, extra_data text, + job_level text default '', status_id text default null, agency boolean default false, create_time timestamp default now () diff --git a/extensions/background-script.js b/extensions/background-script.js index 0b405ef..27e0187 100644 --- a/extensions/background-script.js +++ b/extensions/background-script.js @@ -84,7 +84,6 @@ browser.runtime.onInstalled.addListener(startup); browser.runtime.onStartup.addListener(startup); browser.runtime.onConnect.addListener(startup); browser.menus.onClicked.addListener(async function (e, tab) { - console.log("here") if (e.menuItemId === "mark-page") { console.log("set mark-page", tab.id) await browser.storage.local.set({ diff --git a/extensions/definitions.js b/extensions/definitions.js index a4e90e7..0b66bc6 100644 --- a/extensions/definitions.js +++ b/extensions/definitions.js @@ -9,7 +9,12 @@ browser.runtime.onMessage.addListener((message) => { } const jobTitle = document.querySelector('h1').textContent; const company = document.querySelector('.relative a[target="_self"]').textContent; - const money = document.querySelector('ul li div div div li-icon[type="job"]')?.parentNode?.parentNode?.parentNode?.parentNode?.children[1]?.children[0]?.textContent?.replaceAll(/\s{2,}/g, '') ?? ''; + + const money = + document.querySelector('ul li div div div li-icon[type="job"]')?.parentNode?.parentNode?.parentNode?.parentNode?.children[1]?.children[0]?.textContent?.replaceAll(/\s{2,}/g, '') ?? + Object.values(document.querySelector('button[class="job-details-preferences-and-skills"]')?.children ?? []).find(a => a.innerText.match(/\d/) && !a.innerText.match('skill'))?.innerText ?? + ''; + const description = document.querySelector('article').textContent; browser.runtime.sendMessage({ type: "GOT_INFO_R", company, jobTitle, money, description }); @@ -25,10 +30,10 @@ browser.runtime.onMessage.addListener((message) => { const description = [...document.querySelector('header[data-test="job-details-header"]').parentNode.querySelectorAll('button')].filter(a => a.textContent == "Show more")[0]?.parentNode?.parentNode?.textContent; - let money = "" + let money = document.querySelectorAll('div[class^="SalaryEstimate_salaryRange"]')?.[0]?.innerText ?? ''; const moneySectionNode = document.querySelector('section>section'); - if (moneySectionNode && ["Base pay range", "Base pay"].includes(moneySectionNode.querySelector('h2').textContent)) { + if (moneySectionNode && ["Base pay range", "Base pay"].includes(moneySectionNode.querySelector('h2')?.textContent)) { money = moneySectionNode.querySelector("div>div>div").children[1]?.textContent ?? '' } diff --git a/site/src/app.css b/site/src/app.css index 8c78d30..55a7c4e 100644 --- a/site/src/app.css +++ b/site/src/app.css @@ -3,87 +3,87 @@ @tailwind utilities; @font-face { - font-family: 'JetBrainsMono'; - src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf'); + font-family: 'JetBrainsMono'; + src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf'); } @layer components { - .grad-back { - @apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700; - background-size: 100vw 400vh; - animation: grad-back 100s linear infinite; - } + .grad-back { + @apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700; + background-size: 100vw 400vh; + animation: grad-back 100s linear infinite; + } - @keyframes grad-back { - from { - background-position: 0 0; - } + @keyframes grad-back { + from { + background-position: 0 0; + } - to { - background-position: 0 400vh; - } - } + to { + background-position: 0 400vh; + } + } - .font-JetBrainsMono { - font-family: 'JetBrainsMono'; - } + .font-JetBrainsMono { + font-family: 'JetBrainsMono'; + } - h1 { - @apply text-purple-500 font-bold font-JetBrainsMono text-xl; - } + h1 { + @apply text-purple-500 font-bold font-JetBrainsMono text-xl; + } - dialog { - @apply p-3 rounded-md; - } + dialog { + @apply p-3 rounded-md; + } - dialog::backdrop { - @apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0; - } + dialog::backdrop { + @apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0; + } - .flabel { - @apply block text-purple-500; - } + .flabel { + @apply block text-purple-500; + } - .finput { - @apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1; - } + .finput { + @apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1 bg-white; + } - .finput[type='color'] { - @apply p-0; - } + .finput[type='color'] { + @apply p-0; + } - .btns { - @apply flex justify-center py-2 gap-2; - } + .btns { + @apply flex justify-center py-2 gap-2; + } - .btn-primary { - @apply rounded-lg text-white bg-violet-500 p-2; - } + .btn-primary { + @apply rounded-lg text-white bg-violet-500 p-2; + } - .btn-danger { - @apply rounded-lg bg-danger p-2 text-white; - } + .btn-danger { + @apply rounded-lg bg-danger p-2 text-white; + } - .btn-confirm { - @apply rounded-lg bg-blue-400 text-white p-2; - } + .btn-confirm { + @apply rounded-lg bg-blue-400 text-white p-2; + } - .card { - @apply bg-white rounded-lg drop-shadow-lg; - } + .card { + @apply bg-white rounded-lg drop-shadow-lg; + } } @print { - @page :footer { - display: none; - } + @page :footer { + display: none; + } - @page :header { - display: none; - } + @page :header { + display: none; + } } @page { - size: auto; - margin: 0; + size: auto; + margin: 0; } diff --git a/site/src/lib/ApplicationsStore.svelte.ts b/site/src/lib/ApplicationsStore.svelte.ts index 362012d..d17f3fa 100644 --- a/site/src/lib/ApplicationsStore.svelte.ts +++ b/site/src/lib/ApplicationsStore.svelte.ts @@ -33,6 +33,7 @@ export type Application = { linked_application: string; create_time: string; status_history: string; + job_level: string; flairs: Flair[]; events: ApplicationEvent[]; }; diff --git a/site/src/routes/ApplicationsList.svelte b/site/src/routes/ApplicationsList.svelte index f0f5c98..a5278ad 100644 --- a/site/src/routes/ApplicationsList.svelte +++ b/site/src/routes/ApplicationsList.svelte @@ -1,5 +1,6 @@

To Apply

-
+
{internal.length}
+ {#if !gettingNext} + + {:else} + + {/if}
{#each internal as item} @@ -51,7 +94,10 @@ }} role="none" > -
+

diff --git a/site/src/routes/graphs/+page.svelte b/site/src/routes/graphs/+page.svelte new file mode 100644 index 0000000..50be056 --- /dev/null +++ b/site/src/routes/graphs/+page.svelte @@ -0,0 +1,555 @@ + + + +
+ +
+
+
+ { + if (item.url.includes('linkedin')) { + acc.linkedin += 1; + } else if (item.url.includes('glassdoor')) { + acc.glassdoor += 1; + } else { + acc.other += 1; + } + return acc; + }, + { + linkedin: 0, + glassdoor: 0, + other: 0 + } + )} + /> + { + if (item === 'Created') { + acc[item] = applicationStore.all.filter( + (a) => a.status_id === null + ).length; + return acc; + } + acc[item.name] = applicationStore.all.filter( + (a) => item.id === a.status_id + ).length; + return acc; + }, + {} as Record + )} + /> + a.payrange.match(/\d/)) + .map((a) => { + const payrange = a.payrange + .replace(/[kK]/g, '000') + .replace(/[^\d\-–]/g, '') + .replace(/–/g, '-') + .split('-'); + return Number(payrange[payrange.length - 1]); + }) + .reduce( + (acc, a) => { + const f = Math.floor(a / 10000); + let name = `${f * 10}K-${(f + 1) * 10}K`; + if (f == 0) { + name = '<10K'; + } + if (acc[name]) { + acc[name] += 1; + } else { + acc[name] = 1; + } + return acc; + }, + {} as Record + )} + /> + a.payrange.match(/\d/)) + .map((a) => { + const payrange = a.payrange + .replace(/[kK]/g, '000') + // The first is a - the other is unicode 8211 + .replace(/[^\d\-–]/g, '') + .replace(/–/g, '-') + .split('-'); + return Number(payrange[0]); + }) + .reduce( + (acc, a) => { + const f = Math.floor(a / 10000); + let name = `${f * 10}K-${(f + 1) * 10}K`; + if (f == 0) { + name = '<10K'; + } + if (acc[name]) { + acc[name] += 1; + } else { + acc[name] = 1; + } + return acc; + }, + {} as Record + )} + /> +
+ {#if flairStats !== 'loading'} +
+ +
+
    + {#each Object.keys(flairStats).toSorted((a, b) => flairStats[b] - flairStats[a]) as flair} +
  • {flair}: {flairStats[flair]}
  • + {/each} +
+
+ { + const job_level = a.job_level ? a.job_level : 'Unknown'; + if (acc[job_level]) { + acc[job_level] += 1; + } else { + acc[job_level] = 1; + } + return acc; + }, + {} as Record + )} + sensitivity={0.02} + /> +
+ {/if} +

+ Pay range + + + +

+
+
+ +
+
+ {#if scale && context} + {#each searchPayranges && searchPayRangeMode === 'limit' ? payranges.filter( (a) => a[0].match(new RegExp(searchPayranges, 'i')) ) : payranges as v, index} + {@const company = v[0]} + {@const values = v[1]} + {@const ranges = values.reduce(max_and_min_reducer, [ + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY + ])} + {@const nameCompany = company === '' ? 'No Company' : company} + {#if open !== company} +
(open = company)} + onkeydown={() => (open = company)} + bind:this={indexPayRanges[index]} + tabindex={1} + > +
+
+
+ {#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40} +
+ {nameCompany} +
+ {:else} +
+ {nameCompany} +
+ {/if} +
+ {:else} +
+

+ {nameCompany} (Avg: {( + (ranges[0] + ranges[1]) / + 2 + ).toLocaleString('en-GB', { + notation: 'compact', + currency: 'GBP', + style: 'currency' + })}; Min: {ranges[0].toLocaleString('en-GB', { + notation: 'compact', + currency: 'GBP', + style: 'currency' + })}; Max: {ranges[1].toLocaleString('en-GB', { + notation: 'compact', + currency: 'GBP', + style: 'currency' + })}) +

+ {#each values as app} +
{ + applicationStore.loadItem = app as any; + goto('/'); + }} + onkeydown={() => { + applicationStore.loadItem = app as any; + }} + > + {#if app.payrange[1]} +
+ {/if} +
+ {#if app.payrange[1]} +
+ {/if} + {#if context.measureText(app.title).width < scale(app.payrange[1]) - scale(app.payrange[0]) - 40} +
+ {app.title} +
+ {:else} +
+ {app.title} +
+ {/if} +
+ {/each} +
+ {/if} + {/each} + {/if} +
+
+
+
+
+
diff --git a/site/src/routes/graphs/Pie.svelte b/site/src/routes/graphs/Pie.svelte new file mode 100644 index 0000000..85754f3 --- /dev/null +++ b/site/src/routes/graphs/Pie.svelte @@ -0,0 +1,172 @@ + + +
+ {title} +
+
+
+
diff --git a/site/src/routes/work-area/AutoDropZone.svelte b/site/src/routes/work-area/AutoDropZone.svelte index 15f4fa6..25ed900 100644 --- a/site/src/routes/work-area/AutoDropZone.svelte +++ b/site/src/routes/work-area/AutoDropZone.svelte @@ -51,7 +51,9 @@ {#if applicationStore.dragging && derivedItem} -
+
{#each statusStore.dirLinks[derivedItem.status_id] as node} moveStatus(node.id, node.endable)} >{node.name} void, icon: string, children: Snippet } = $props(); + let { ondrop, icon, children }: { ondrop: () => void; icon: string; children: Snippet } = + $props();
{})} {ondrop} > -
{@render children()}
diff --git a/site/src/routes/work-area/LinkApplication.svelte b/site/src/routes/work-area/LinkApplication.svelte index 1ebfd45..a2df991 100644 --- a/site/src/routes/work-area/LinkApplication.svelte +++ b/site/src/routes/work-area/LinkApplication.svelte @@ -13,7 +13,11 @@ onreload: (item: Application) => void; } = $props(); - let filter = $state(''); + let filter = $state(application.company ? `@ ${application.company}` : ''); + + $effect(() => { + filter = application.company ? `@ ${application.company}` : ''; + }); async function submit(item: Application) { try { @@ -29,6 +33,7 @@ let internal = $derived( applicationStore.all.filter((i) => { + if (i.id === application.id) return false; if (!filter) { return true; } @@ -46,9 +51,9 @@ -
+
-
+
{internal.length}
diff --git a/site/src/routes/work-area/NewUrlDialog.svelte b/site/src/routes/work-area/NewUrlDialog.svelte index 4b4292e..ee26bb0 100644 --- a/site/src/routes/work-area/NewUrlDialog.svelte +++ b/site/src/routes/work-area/NewUrlDialog.svelte @@ -83,7 +83,18 @@
- +
diff --git a/site/src/routes/work-area/Timeline.svelte b/site/src/routes/work-area/Timeline.svelte index 866fcda..2aa9346 100644 --- a/site/src/routes/work-area/Timeline.svelte +++ b/site/src/routes/work-area/Timeline.svelte @@ -60,6 +60,12 @@ lastEvent = event; } + let d2 = new Date().getTime(); + if (_events.length > 0) { + let d1 = new Date(_events[_events.length - 1].time).getTime(); + _events[_events.length - 1].timeDiff = calcDiff(d2 - d1); + } + // Todo endable /* if (_events.length > 0 && !endable.includes(_events[_events.length - 1].new_status_id)) { @@ -85,10 +91,14 @@ class="shadow-sm shadow-violet-500 border rounded-full min-w-[50px] min-h-[50px] grid place-items-center bg-white z-10" > {#if event.event_type == EventType.Creation} - {:else if event.event_type == EventType.View} - {:else} {#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable} -
+
{#if event.timeDiff}
diff --git a/site/src/routes/work-area/WorkArea.svelte b/site/src/routes/work-area/WorkArea.svelte index 559f516..d418eda 100644 --- a/site/src/routes/work-area/WorkArea.svelte +++ b/site/src/routes/work-area/WorkArea.svelte @@ -14,7 +14,6 @@ import CompanyField from './CompanyField.svelte'; import AutoDropZone from './AutoDropZone.svelte'; import { statusStore } from '$lib/Types.svelte'; - import { thresholdFreedmanDiaconis } from 'd3'; // Not this represents the index in the store array let activeItem: number | undefined = $state(); @@ -124,9 +123,27 @@ function setExtData() { if (!lastExtData || activeItem === undefined || !derivedItem) return; - applicationStore.all[activeItem].title = lastExtData.jobTitle; - applicationStore.all[activeItem].company = lastExtData.company; + applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&/, '&'); + applicationStore.all[activeItem].company = lastExtData.company.replace(/\&/, '&'); applicationStore.all[activeItem].payrange = lastExtData.money; + + const title: string = lastExtData.jobTitle; + if (title.match(/intern|apprenticeship/i)) { + applicationStore.all[activeItem].job_level = 'intern'; + } else if (title.match(/graduate/i)) { + applicationStore.all[activeItem].job_level = 'entry'; + } else if (title.match(/junior|associate/i)) { + applicationStore.all[activeItem].job_level = 'junior'; + } else if (title.match(/mid/i)) { + applicationStore.all[activeItem].job_level = 'mid'; + } else if (title.match(/senior|III/i)) { + applicationStore.all[activeItem].job_level = 'senior'; + } else if (title.match(/staff/i)) { + applicationStore.all[activeItem].job_level = 'staff'; + } else if (title.match(/lead/i)) { + applicationStore.all[activeItem].job_level = 'lead'; + } + window.requestAnimationFrame(() => { save().then(async () => { if (activeItem === undefined) return; @@ -274,6 +291,23 @@
{/if} +
+ + +
Tags