chore: various updates

This commit is contained in:
Andre Henriques 2025-03-26 13:51:49 +00:00
parent 941875ff21
commit eea2d6c3df
24 changed files with 1527 additions and 236 deletions

3
api/.gitignore vendored
View File

@ -38,3 +38,6 @@ out/
### Kotlin ###
.kotlin
### secrets ###
application.properties

View File

@ -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

View File

@ -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<Test> { useJUnitPlatform() }
tasks.withType<Test> { useJUnitPlatform() }

View File

@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=17

View File

@ -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<String>) {
runApplication<ApplicationsApplication>(*args)
}

View File

@ -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<Flair>,
var events: List<Event>,
) {
@ -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<Application> {
val user = sessionService.verifyTokenThrow(token)
var text = submit.text.replace("=\n", "")
var urls: List<String> = 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("&amp;", "&").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<Application> {
var text = textIn.replace("=\n", "")
var urls: List<String> = 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("&amp;", "&").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,
)
}
}

View File

@ -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
}
}

View File

@ -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<FlairStatItem> {
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<SimpleFlair> {
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<FlairStatItem> {
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<FlairStatItem> =
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<Flair> =
db.query(
"select * from flair where user_id=? and description != '' order by name asc;",

View File

@ -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<Application> {
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<String>().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<String>()
.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<String> {
if (it.statusCode().value().equals(200)) {
it.bodyToMono<String>()
} else {
println("here ${it.statusCode()} ${it.statusCode().value()}")
Mono.just("asdfasdf")
}
}
.block()!!
// .bodyToMono<String>()
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<Application> = arrayListOf()
if (!email_text_obj.contains("Don&#x27;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<String>()
.block()!!
return applications
}
@GetMapping(path = ["/getNext"], produces = [MediaType.APPLICATION_JSON_VALUE])
public fun getCV(@RequestHeader("token") token: String): List<Application> {
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()
}
}

View File

@ -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<UserDb> {
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

View File

@ -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 ()

View File

@ -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({

View File

@ -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 ?? ''
}

View File

@ -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;
}

View File

@ -33,6 +33,7 @@ export type Application = {
linked_application: string;
create_time: string;
status_history: string;
job_level: string;
flairs: Flair[];
events: ApplicationEvent[];
};

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { applicationStore } from '$lib/ApplicationsStore.svelte';
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
import { get } from '$lib/utils';
import { onMount } from 'svelte';
let filter = $state('');
@ -28,15 +29,57 @@
return x.match(f);
})
);
let gettingNext = $state(false);
async function getNext() {
gettingNext = true;
try {
const r: Application[] = await get('mail/getNext');
for (const app of r) {
applicationStore.all.push(app);
}
if (r.length > 0) {
applicationStore.loadItem = r[0];
}
} catch (e) {
console.error('TODO inform user', e);
} finally {
gettingNext = false;
}
}
function docKey(e: KeyboardEvent) {
if (e.ctrlKey && e.code === 'KeyJ' && internal.length > 0) {
applicationStore.loadItem = internal[0];
e.stopPropagation();
e.preventDefault();
return;
}
}
$effect(() => {
document.addEventListener('keydown', docKey, false);
return () => {
document.removeEventListener('keydown', docKey);
};
});
</script>
<div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0">
<h1>To Apply</h1>
<div class="flex pb-2">
<div class="flex pb-2 items-center">
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
<div>
{internal.length}
</div>
{#if !gettingNext}
<button class="p-2 text-violet-500" onclick={() => getNext()}>
<span class="bi bi-send-arrow-down"></span>
</button>
{:else}
<span class="bi bi-arrow-repeat animate-spin"></span>
{/if}
</div>
<div class="overflow-auto flex-grow p-2">
{#each internal as item}
@ -51,7 +94,10 @@
}}
role="none"
>
<div class="max-w-full" class:animate-pulse={applicationStore.dragging?.id === item.id}>
<div
class="max-w-full"
class:animate-pulse={applicationStore.dragging?.id === item.id}
>
<h2 class="text-lg text-blue-500 flex gap-2 max-w-full overflow-hidden">
<div class="flex-grow max-w-[90%]">
<div class="whitespace-nowrap overflow-hidden">

View File

@ -0,0 +1,555 @@
<script lang="ts">
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
import HasUser from '$lib/HasUser.svelte';
import { onMount } from 'svelte';
import NavBar from '../NavBar.svelte';
import Pie from './Pie.svelte';
import { statusStore } from '$lib/Types.svelte';
import * as d3 from 'd3';
import { goto } from '$app/navigation';
import { get } from '$lib/utils';
import { flairStore } from '$lib/FlairStore.svelte';
onMount(() => {
applicationStore.loadAll();
statusStore.load();
});
let sort: 'asc' | 'desc' = $state('desc');
const payranges = $derived.by(() => {
const obj = applicationStore.all
.filter((a) => 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(/[^\d\-]/g, '')
.replace(//g, '-')
.split('-')
.map((a) => Number(a));
if (Number.isNaN(payrange[0])) {
payrange[0] = 0;
}
if (Number.isNaN(payrange[1])) {
payrange[1] = 0;
}
return { ...a, payrange };
})
.reduce(
(acc, a) => {
if (!acc[a.company]) {
acc[a.company] = [a];
} else {
acc[a.company].push(a);
}
return acc;
},
{} as Record<string, (Omit<Application, 'payrange'> & { payrange: number[] })[]>
);
return Object.keys(obj)
.reduce(
(acc, a) => {
acc.push([a, obj[a]]);
return acc;
},
[] as [string, (Omit<Application, 'payrange'> & { payrange: number[] })[]][]
)
.toSorted((a, b) => {
const rangesA = a[1].reduce(max_and_min_reducer, [
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY
]);
const rangesB = b[1].reduce(max_and_min_reducer, [
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY
]);
const va = (rangesA[1] + rangesA[0]) / 2;
const vb = (rangesB[1] + rangesB[0]) / 2;
if (sort === 'asc') {
return va - vb;
} else if (sort === 'desc') {
return vb - va;
}
return 0;
});
});
let payRangeDiv: HTMLDivElement | undefined = $state(undefined);
function max_and_min_reducer(
acc: [number, number],
a: Omit<Application, 'payrange'> & { payrange: number[] }
): [number, number] {
/*if (a.payrange[0] > 1000000 || a.payrange[1] > 1000000) {
console.log(a);
}*/
return [
Math.min(acc[0], a.payrange[0]),
Math.max(acc[1], a.payrange[1] ?? 0, a.payrange[0])
];
}
const scale = $derived.by(() => {
if (!payRangeDiv) return;
const max_and_min = Object.values(payranges).reduce(
(acc, a) => {
return a[1].reduce(
(acc2, e) => max_and_min_reducer(acc2, e),
acc as [number, number]
);
},
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] as [number, number]
);
const box = payRangeDiv.getBoundingClientRect();
const scale = d3
.scaleLinear()
.domain([max_and_min[0], max_and_min[1]])
.range([0, box.width - 40]);
return scale;
});
const context = (() => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) return null;
context.font = '12px Open sans';
return context;
})();
let open = $state<string | undefined>(undefined);
let searchPayranges = $state('');
let expandPayRanges = $state(false);
let searchPayRangeMode: 'limit' | 'goto' = $state('goto');
let indexPayRanges: HTMLDivElement[] = $state([]);
let gotoIndex: number | undefined = $state(undefined);
$effect(() => {
if (searchPayRangeMode !== 'goto' || !searchPayranges) {
gotoIndex = undefined;
return;
}
let i = 0;
for (let [company] of payranges) {
if (company.match(new RegExp(searchPayranges, 'i'))) {
indexPayRanges[i].scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'center'
});
gotoIndex = i;
return;
}
i += 1;
}
gotoIndex = undefined;
});
let flairStats: 'loading' | Record<string, number> = $state('loading');
onMount(async () => {
const items: any[] = await get('flair/stats');
flairStats = items.reduce(
(acc, a) => {
acc[a.name] = a.count;
return acc;
},
{} as Record<string, number>
);
});
</script>
<HasUser redirect="/cv">
<div class="flex flex-col h-[100vh]">
<NavBar />
<div class="p-1 px-5">
<div class="bg-white p-3 rounded-lg gap-5 flex flex-col">
<div class="flex gap-5 flex-wrap">
<Pie
title={'Origin'}
data={applicationStore.all.reduce(
(acc, item) => {
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
}
)}
/>
<Pie
title={'Status'}
data={[...statusStore.nodes, 'Created' as const].reduce(
(acc, item) => {
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<string, number>
)}
/>
<Pie
title={'Higher range Pay Range'}
data={applicationStore.all
.filter((a) => 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<string, number>
)}
/>
<Pie
title={'Lower range Pay Range'}
data={applicationStore.all
.filter((a) => 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<string, number>
)}
/>
</div>
{#if flairStats !== 'loading'}
<div class="flex gap-5">
<Pie title={'Flair stats'} data={flairStats} sensitivity={0.02} />
<div class="max-h-[500px] overflow-auto">
<ul>
{#each Object.keys(flairStats).toSorted((a, b) => flairStats[b] - flairStats[a]) as flair}
<li>{flair}: {flairStats[flair]}</li>
{/each}
</ul>
</div>
<Pie
title={'Job Level'}
data={applicationStore.all.reduce(
(acc, a) => {
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<string, number>
)}
sensitivity={0.02}
/>
</div>
{/if}
<h1 class="text-black">
Pay range
<button
title="Expand/Contract"
onclick={() => {
expandPayRanges = !expandPayRanges;
}}
>
<span
class={!expandPayRanges
? 'bi bi-arrows-angle-expand'
: 'bi bi-arrows-angle-contract'}
></span>
</button>
<button
title="Sorting"
onclick={() => {
if (sort === 'asc') {
sort = 'desc';
} else if (sort === 'desc') {
sort = 'asc';
}
}}
>
<span class={sort === 'asc' ? 'bi bi-arrow-down' : 'bi bi-arrow-up'}></span>
</button>
<button
title="Filter mode"
onclick={() => {
if (searchPayRangeMode === 'limit') {
searchPayRangeMode = 'goto';
} else if (searchPayRangeMode === 'goto') {
searchPayRangeMode = 'limit';
}
}}
>
<span
class={searchPayRangeMode === 'limit'
? 'bi bi-funnel'
: 'bi bi-sort-alpha-down'}
></span>
</button>
</h1>
<div
class="bg-white {expandPayRanges
? ''
: 'min-h-[500px] max-h-[500px]'} overflow-y-auto"
>
<div class="sticky top-0 py-2 px-2 bg-white w-full z-50">
<input
class="w-full z-20"
bind:value={searchPayranges}
placeholder="search"
/>
</div>
<div bind:this={payRangeDiv}>
{#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}
<div
class="relative h-[40px] pointer-cursor {gotoIndex === index
? 'bg-purple-200/50'
: index % 2 === 0
? 'bg-slate-50'
: ''}"
role="button"
onclick={() => (open = company)}
onkeydown={() => (open = company)}
bind:this={indexPayRanges[index]}
tabindex={1}
>
<div
class="bg-blue-500 w-[20px] h-[10px] rounded-full absolute"
style="left: {10 +
scale(ranges[0]) +
10}px; top: 50%; transform: translateY(-50%); width: {scale(
ranges[1]
) - scale(ranges[0])}px;"
></div>
<div
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
title={`pay: ${ranges[0].toLocaleString('en-GB', {
notation: 'compact',
currency: 'GBP',
style: 'currency'
})}`}
style="left: {10 +
scale(
ranges[0]
)}px; top: 50%; transform: translateY(-50%);"
></div>
<div
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
title={`pay: ${ranges[1].toLocaleString('en-GB', {
notation: 'compact',
currency: 'GBP',
style: 'currency'
})}`}
style="left: {10 +
scale(
ranges[1]
)}px; top: 50%; transform: translateY(-50%);"
></div>
{#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40}
<div
class="absolute text-center text-white font-bold pb-1"
style="left: {10 +
scale(ranges[0]) +
10}px; width: {scale(ranges[1]) -
scale(ranges[0])}px;
top: 50%; transform: translateY(-50%); font-size: 10px; "
>
{nameCompany}
</div>
{:else}
<div
class="absolute text-center font-bold pb-1"
style="left: {10 +
scale(ranges[1] ?? ranges[0]) +
30}px;
top: 50%; transform: translateY(-50%); font-size: 10px; "
>
{nameCompany}
</div>
{/if}
</div>
{:else}
<div
class=" p-[10px] inset-2
{gotoIndex === index
? 'bg-purple-200/50'
: 'bg-slate-200/50'}
"
bind:this={indexPayRanges[index]}
>
<h2 class="font-bold">
{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'
})})
</h2>
{#each values as app}
<div
class="relative -mx-[10px] h-[40px]"
role="button"
tabindex={1}
onclick={() => {
applicationStore.loadItem = app as any;
goto('/');
}}
onkeydown={() => {
applicationStore.loadItem = app as any;
}}
>
{#if app.payrange[1]}
<div
class="bg-blue-500 w-[20px] h-[10px] rounded-full absolute"
style="left: {10 +
scale(app.payrange[0]) +
10}px; top: 50%; transform: translateY(-50%); width: {scale(
app.payrange[1]
) - scale(app.payrange[0])}px;"
></div>
{/if}
<div
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
title={`pay: ${app.payrange[0].toLocaleString(
'en-GB',
{
notation: 'compact',
currency: 'GBP',
style: 'currency'
}
)}`}
style="left: {10 +
scale(
app.payrange[0]
)}px; top: 50%; transform: translateY(-50%);"
></div>
{#if app.payrange[1]}
<div
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
title={`pay: ${app.payrange[1].toLocaleString(
'en-GB',
{
notation: 'compact',
currency: 'GBP',
style: 'currency'
}
)}`}
style="left: {10 +
scale(
app.payrange[1]
)}px; top: 50%; transform: translateY(-50%);"
></div>
{/if}
{#if context.measureText(app.title).width < scale(app.payrange[1]) - scale(app.payrange[0]) - 40}
<div
class="absolute text-center text-white font-bold pb-1"
style="left: {10 +
scale(app.payrange[0]) +
10}px; width: {scale(app.payrange[1]) -
scale(app.payrange[0])}px;
top: 50%; transform: translateY(-50%); font-size: 10px; "
>
{app.title}
</div>
{:else}
<div
class="absolute text-center font-bold pb-1"
style="left: {10 +
scale(
app.payrange[1] ?? app.payrange[0]
) +
30}px;
top: 50%; transform: translateY(-50%); font-size: 10px; "
>
{app.title}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
</HasUser>

View File

@ -0,0 +1,172 @@
<script lang="ts">
import * as d3 from 'd3';
const {
title,
data,
sensitivity = 0.01,
width = 500,
height = 500,
dounout = false
}: {
title: string;
data: Record<string, number>;
sensitivity?: number;
width?: number;
height?: number;
dounout?: boolean;
} = $props();
let div: HTMLDivElement;
$effect(() => {
if (!div) return;
const labelsArray = Object.keys(data); //.toSorted((a, b) => data[a] - data[b]);
const valueArray = labelsArray.map((a) => data[a]);
const sum = valueArray.reduce((acc, d) => acc + d, 0);
let dataf = labelsArray
.map((l) => ({ name: l, value: data[l] }))
.filter((f) => f.value / sum > sensitivity);
const otherSum = valueArray.reduce((acc, v) => {
if (v / sum > sensitivity) return acc;
return acc + v;
}, 0);
if (otherSum > 0) {
dataf.push({
value: otherSum,
name: 'Other'
});
}
dataf = dataf.toSorted((a, b) => a.value - b.value);
const names = dataf.map((d) => d.name);
const values = dataf.map((d) => d.value);
const range = d3.range(names.length).filter((i) => !Number.isNaN(values[i]));
let colors: readonly string[] = [];
const groups = names;
const len = groups.length;
if (len === 0) {
// Do nothing
} else if (len === 1) {
colors = ['#FFCCC9'];
} else if (len === 2) {
colors = ['#FFCCC9', '#41EAD4'];
} else if (len < 10) {
colors = d3.schemeBlues[len];
} else {
colors = [...groups].map((_, i) => d3.interpolateBlues(i / len));
}
const color = d3.scaleOrdinal(groups, colors);
const innerRadius = 0; // inner radius of pie, in pixels (non-zero for donut)
const outerRadius = Math.min(width, height) / 2; // outer radius of pie, in pixels
const labelRadius = innerRadius * 0.2 + outerRadius * 0.8; // center radius of labels
const stroke = innerRadius > 0 ? 'none' : 'white'; // stroke separating widths
const strokeWidth = 1; // width of stroke separating wedges
const strokeLinejoin = 'round'; // line join of stroke separating wedges
const padAngle = stroke === 'none' ? 1 / outerRadius : 0; // angular separation between wedges, in radians
const arcs = d3
.pie()
.padAngle(padAngle)
.sort(null)
.value((i) => values[i as number])(range);
const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);
const arcLabel = d3.arc().innerRadius(labelRadius).outerRadius(labelRadius);
const svg = d3
.create('svg')
.attr('width', width * 2)
.attr('height', height * 2)
.attr('viewBox', [-width, -height, width * 2, height * 2]);
const shapes = svg
.append('g')
.attr('stroke', stroke)
.attr('stroke-width', strokeWidth)
.attr('stroke-linejoin', strokeLinejoin);
const svg_arcs = shapes
.selectAll('path')
.data(arcs)
.join('path')
.attr('fill', (d, i) => color(`${names[d.data as number]}-${i}`))
.attr('d', arc as unknown as number);
svg_arcs
.append('title')
.text((d) => `${names[d.data as number]}: ${values[d.data as number]}`);
svg.append('g')
.attr('font-family', 'sans-serif')
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.selectAll('text')
.data(arcs)
.join('text')
.attr('transform', (d) => `translate(${arcLabel.centroid(d as unknown as any)})`)
.selectAll('tspan')
.data((d) => {
if (d.endAngle - d.startAngle <= 0.2) {
return [];
}
if (d.endAngle - d.startAngle <= 0.25) {
return [{ t: 'title', text: names[d.data as number] }];
}
return [
{ t: 'title', text: names[d.data as number] },
{
t: 'value',
text: values[d.data as number].toLocaleString('en-GB', {
notation: 'compact'
})
}
];
})
.join('tspan')
.attr('x', 0)
.attr('y', (_, i) => `${i * 1.1}em`)
.attr('font-weight', (d) => (d.t === 'value' ? null : 'bold'))
.text((d) => {
return d.text;
});
if (dounout) {
const mask = svg.append('mask').attr('id', 'center-mask');
mask.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('fill', 'white')
.attr('r', width);
mask.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('fill', 'black')
.attr('r', width / 4);
shapes.attr('mask', 'url(#center-mask)');
}
div.innerHTML = '';
div.appendChild(svg.node() as unknown as Node);
});
</script>
<div>
{title}
<div class="relative m-auto overflow-hidden" style="width: {width}px; height: {height}px; ">
<div bind:this={div} style="transform: translate(-25%, -25%)" class="m-auto absolute"></div>
</div>
</div>

View File

@ -51,7 +51,9 @@
</script>
{#if applicationStore.dragging && derivedItem}
<div class="flex w-full flex-grow rounded-lg p-3 gap-2 absolute bottom-0 left-0 right-0 bg-white">
<div
class="flex w-full flex-grow rounded-lg p-3 gap-2 absolute bottom-0 left-0 right-0 bg-white"
>
{#each statusStore.dirLinks[derivedItem.status_id] as node}
<DropZone icon={node.icon} ondrop={() => moveStatus(node.id, node.endable)}
>{node.name}</DropZone

View File

@ -3,7 +3,8 @@
import { preventDefault } from '$lib/utils';
import type { Snippet } from 'svelte';
let { ondrop, icon, children }: { ondrop: () => void, icon: string, children: Snippet } = $props();
let { ondrop, icon, children }: { ondrop: () => void; icon: string; children: Snippet } =
$props();
</script>
<div
@ -13,9 +14,7 @@
ondragenter={preventDefault(() => {})}
{ondrop}
>
<span
class="bi bi-{icon} text-7xl absolute"
class:animate-bounce={applicationStore.dragging}
<span class="bi bi-{icon} text-7xl absolute" class:animate-bounce={applicationStore.dragging}
></span>
<span class="bi bi-{icon} text-7xl opacity-0"></span>
<div class="text-xl">{@render children()}</div>

View File

@ -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 @@
</script>
<dialog class="card max-w-[50vw]" bind:this={dialog}>
<div class="flex">
<div class="flex items-center">
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
<div>
<div class="p-2">
{internal.length}
</div>
</div>

View File

@ -83,7 +83,18 @@
<form bind:this={form} onsubmit={preventDefault(submit)}>
<fieldset>
<label class="flabel" for="text">Url</label>
<textarea class="finput min-w-96 min-h-96" id="text" bind:value={data.url}></textarea>
<textarea
class="finput min-w-96 min-h-96"
id="text"
bind:value={data.url}
onkeydown={(e) => {
if (e.key === 'Enter' && data.url === '' && hasExtension) {
e.preventDefault();
askForUrl();
return;
}
}}
></textarea>
</fieldset>
<div class="btns">
<button type="submit" class="btn-confirm">Update</button>

View File

@ -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}
<span title={`Created @\n ${new Date(event.time).toLocaleString()}`} class="bi bi-plus"
<span
title={`Created @\n ${new Date(event.time).toLocaleString()}`}
class="bi bi-plus"
></span>
{:else if event.event_type == EventType.View}
<span title={`Viewed @\n ${new Date(event.time).toLocaleString()}`} class="bi bi-eye"
<span
title={`Viewed @\n ${new Date(event.time).toLocaleString()}`}
class="bi bi-eye"
></span>
{:else}
<span
@ -102,7 +112,9 @@
<!-- TODO -->
<!-- || !endable.includes(event.new_status) -->
{#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable}
<div class="min-w-[70px] h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center">
<div
class="min-w-[70px] h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center"
>
{#if event.timeDiff}
<div class="-mt-[3px] text-white">
<span class="bi bi-clock"></span>

View File

@ -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(/\&amp;/, '&');
applicationStore.all[activeItem].company = lastExtData.company.replace(/\&amp;/, '&');
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 @@
</div>
</fieldset>
{/if}
<fieldset>
<label class="flabel" for="title">Job Level</label>
<select
class="finput"
id="job_level"
bind:value={applicationStore.all[activeItem].job_level}
onchange={save}
>
<option value="intern"> Intern </option>
<option value="entry"> Entry </option>
<option value="junior"> Junior </option>
<option value="mid"> Mid </option>
<option value="senior"> Senior </option>
<option value="staff"> Staff </option>
<option value="lead"> Lead </option>
</select>
</fieldset>
<div>
<div class="flabel">Tags</div>
<div class="flex gap-2 flex-wrap">