chore: various updates
This commit is contained in:
parent
941875ff21
commit
eea2d6c3df
3
api/.gitignore
vendored
3
api/.gitignore
vendored
@ -38,3 +38,6 @@ out/
|
|||||||
|
|
||||||
### Kotlin ###
|
### Kotlin ###
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### secrets ###
|
||||||
|
application.properties
|
||||||
|
@ -5,6 +5,9 @@ spring.datasource.password=applications
|
|||||||
spring-boot.run.jvmArguments=-Duser.timezone=UTC
|
spring-boot.run.jvmArguments=-Duser.timezone=UTC
|
||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
|
mail.token.api="TODO"
|
||||||
|
mail.local.user="TODO"
|
||||||
|
|
||||||
# Disable the trace on the error responses
|
# Disable the trace on the error responses
|
||||||
server.error.include-stacktrace=never
|
server.error.include-stacktrace=never
|
||||||
|
|
@ -19,17 +19,20 @@ dependencies {
|
|||||||
implementation("org.springframework.security:spring-security-crypto:6.0.3")
|
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-data-jpa")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-mustache")
|
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("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
implementation("org.hibernate.orm:hibernate-community-dialects")
|
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")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|
||||||
implementation("org.bouncycastle:bcprov-jdk18on:1.76")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
springBoot { mainClass.set("com.andr3h3nriqu3s.applications.ApplicationsApplicationKt") }
|
springBoot { mainClass.set("com.andr3h3nriqu3s.applications.ApplicationsApplicationKt") }
|
||||||
@ -41,5 +44,3 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> { useJUnitPlatform() }
|
tasks.withType<Test> { useJUnitPlatform() }
|
||||||
|
|
||||||
tasks.withType<Test> { useJUnitPlatform() }
|
|
||||||
|
2
api/gradle/gradle-daemon-jvm.properties
Normal file
2
api/gradle/gradle-daemon-jvm.properties
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainVersion=17
|
@ -2,11 +2,10 @@ package com.andr3h3nriqu3s.applications
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication @EnableScheduling class ApplicationsApplication
|
||||||
class ApplicationsApplication
|
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
runApplication<ApplicationsApplication>(*args)
|
runApplication<ApplicationsApplication>(*args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,6 +49,7 @@ data class Application(
|
|||||||
var message: String,
|
var message: String,
|
||||||
var create_time: String,
|
var create_time: String,
|
||||||
var simple_url: String,
|
var simple_url: String,
|
||||||
|
var job_level: String,
|
||||||
var flairs: List<Flair>,
|
var flairs: List<Flair>,
|
||||||
var events: List<Event>,
|
var events: List<Event>,
|
||||||
) {
|
) {
|
||||||
@ -68,6 +69,7 @@ data class Application(
|
|||||||
rs.getString("message"),
|
rs.getString("message"),
|
||||||
rs.getString("create_time"),
|
rs.getString("create_time"),
|
||||||
rs.getString("simple_url"),
|
rs.getString("simple_url"),
|
||||||
|
rs.getString("job_level"),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
)
|
)
|
||||||
@ -113,7 +115,7 @@ class ApplicationsController(
|
|||||||
eventService.create(application.id, EventType.View)
|
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(
|
return CVData(
|
||||||
application.company,
|
application.company,
|
||||||
@ -147,6 +149,7 @@ class ApplicationsController(
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
|
"",
|
||||||
emptyList(),
|
emptyList(),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
)
|
)
|
||||||
@ -198,92 +201,7 @@ class ApplicationsController(
|
|||||||
@RequestHeader("token") token: String
|
@RequestHeader("token") token: String
|
||||||
): List<Application> {
|
): List<Application> {
|
||||||
val user = sessionService.verifyTokenThrow(token)
|
val user = sessionService.verifyTokenThrow(token)
|
||||||
|
return applicationService.parseText(user, submit.text)
|
||||||
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("&", "&").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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = ["/list"], produces = [MediaType.APPLICATION_JSON_VALUE])
|
@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)
|
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("&", "&").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 {
|
public fun createApplication(user: UserDb, application: Application): Boolean {
|
||||||
if (this.findApplicationByUrl(user, application.url) != null) {
|
if (this.findApplicationByUrl(user, application.url) != null) {
|
||||||
return false
|
return false
|
||||||
@ -516,7 +522,7 @@ class ApplicationService(
|
|||||||
|
|
||||||
// Create time is auto created by the database
|
// Create time is auto created by the database
|
||||||
db.update(
|
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.id,
|
||||||
application.url,
|
application.url,
|
||||||
application.title,
|
application.title,
|
||||||
@ -529,6 +535,7 @@ class ApplicationService(
|
|||||||
application.message,
|
application.message,
|
||||||
application.agency,
|
application.agency,
|
||||||
application.simple_url,
|
application.simple_url,
|
||||||
|
application.job_level,
|
||||||
)
|
)
|
||||||
|
|
||||||
eventService.create(application.id, EventType.Creation)
|
eventService.create(application.id, EventType.Creation)
|
||||||
@ -576,7 +583,7 @@ class ApplicationService(
|
|||||||
public fun update(application: Application): Application {
|
public fun update(application: Application): Application {
|
||||||
// I don't want ot update create_time
|
// I don't want ot update create_time
|
||||||
db.update(
|
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.url,
|
||||||
application.title,
|
application.title,
|
||||||
application.user_id,
|
application.user_id,
|
||||||
@ -587,6 +594,7 @@ class ApplicationService(
|
|||||||
application.message,
|
application.message,
|
||||||
application.agency,
|
application.agency,
|
||||||
application.simple_url,
|
application.simple_url,
|
||||||
|
application.job_level,
|
||||||
application.id,
|
application.id,
|
||||||
)
|
)
|
||||||
return application
|
return application
|
||||||
@ -609,5 +617,13 @@ class ApplicationService(
|
|||||||
"delete from applications_urls where application_id=?",
|
"delete from applications_urls where application_id=?",
|
||||||
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,71 +1,65 @@
|
|||||||
package com.andr3h3nriqu3s.appliations
|
package com.andr3h3nriqu3s.appliations
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration
|
import com.andr3h3nriqu3s.applications.BadRequest
|
||||||
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.NoToken
|
import com.andr3h3nriqu3s.applications.NoToken
|
||||||
import com.andr3h3nriqu3s.applications.NotAuth
|
import com.andr3h3nriqu3s.applications.NotAuth
|
||||||
import com.andr3h3nriqu3s.applications.UserNotFound
|
|
||||||
import com.andr3h3nriqu3s.applications.NotFound
|
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
|
@Configuration
|
||||||
|
@EnableScheduling
|
||||||
@EnableWebMvc
|
@EnableWebMvc
|
||||||
class WebConfig : WebMvcConfigurer {
|
class WebConfig : WebMvcConfigurer {
|
||||||
|
|
||||||
override public fun configureContentNegotiation(configurer: ContentNegotiationConfigurer){
|
override public fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) {
|
||||||
// configurer.defaultContentType( MediaType.APPLICATION_JSON );
|
// configurer.defaultContentType( MediaType.APPLICATION_JSON );
|
||||||
configurer.mediaType("json", MediaType.APPLICATION_JSON)
|
configurer.mediaType("json", MediaType.APPLICATION_JSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
override public fun addCorsMappings(registry: CorsRegistry) {
|
override public fun addCorsMappings(registry: CorsRegistry) {
|
||||||
registry.addMapping("*").allowedOrigins("*")
|
registry.addMapping("*").allowedOrigins("*")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class RestResponseStatusExceptionResolver : AbstractHandlerExceptionResolver() {
|
public class RestResponseStatusExceptionResolver : AbstractHandlerExceptionResolver() {
|
||||||
|
|
||||||
override protected fun doResolveException(
|
override protected fun doResolveException(
|
||||||
requeset: HttpServletRequest,
|
requeset: HttpServletRequest,
|
||||||
response: HttpServletResponse,
|
response: HttpServletResponse,
|
||||||
handler: Any?,
|
handler: Any?,
|
||||||
ex: Exception
|
ex: Exception
|
||||||
): ModelAndView? {
|
): ModelAndView? {
|
||||||
try {
|
try {
|
||||||
if (ex is NoToken || ex is NotAuth || ex is UserNotFound) {
|
if (ex is NoToken || ex is NotAuth || ex is UserNotFound) {
|
||||||
response.sendError(HttpServletResponse.SC_FORBIDDEN)
|
response.sendError(HttpServletResponse.SC_FORBIDDEN)
|
||||||
return ModelAndView()
|
return ModelAndView()
|
||||||
}
|
}
|
||||||
if (ex is NotFound) {
|
if (ex is NotFound) {
|
||||||
response.sendError(HttpServletResponse.SC_NOT_FOUND)
|
response.sendError(HttpServletResponse.SC_NOT_FOUND)
|
||||||
return ModelAndView()
|
return ModelAndView()
|
||||||
}
|
}
|
||||||
if (ex is BadRequest) {
|
if (ex is BadRequest) {
|
||||||
response.sendError(HttpServletResponse.SC_BAD_REQUEST)
|
response.sendError(HttpServletResponse.SC_BAD_REQUEST)
|
||||||
return ModelAndView()
|
return ModelAndView()
|
||||||
}
|
}
|
||||||
} catch (handlerException: Exception) {
|
} catch (handlerException: Exception) {
|
||||||
print("Faield to handle exception ")
|
print("Faield to handle exception ")
|
||||||
print(handlerException)
|
print(handlerException)
|
||||||
print("\n")
|
print("\n")
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -63,6 +63,12 @@ class FlairController(
|
|||||||
return flairService.listUser(user)
|
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])
|
@GetMapping(path = ["/simple/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
public fun listSimple(@PathVariable id: String): List<SimpleFlair> {
|
public fun listSimple(@PathVariable id: String): List<SimpleFlair> {
|
||||||
var application = applicationService.findApplicationByIdNoUser(id)
|
var application = applicationService.findApplicationByIdNoUser(id)
|
||||||
@ -88,6 +94,19 @@ data class SimpleFlair(
|
|||||||
val showFullDescription: Int,
|
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(
|
data class Flair(
|
||||||
var id: String,
|
var id: String,
|
||||||
var user_id: String,
|
var user_id: String,
|
||||||
@ -182,6 +201,14 @@ public class FlairService(val db: JdbcTemplate) {
|
|||||||
)
|
)
|
||||||
.toList()
|
.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> =
|
public fun listUserId(id: String): List<Flair> =
|
||||||
db.query(
|
db.query(
|
||||||
"select * from flair where user_id=? and description != '' order by name asc;",
|
"select * from flair where user_id=? and description != '' order by name asc;",
|
||||||
|
391
api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt
Normal file
391
api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt
Normal 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'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()
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
package com.andr3h3nriqu3s.applications
|
package com.andr3h3nriqu3s.applications
|
||||||
|
|
||||||
|
import java.sql.ResultSet
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.jdbc.core.RowMapper
|
||||||
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
|
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice
|
import org.springframework.web.bind.annotation.ControllerAdvice
|
||||||
@ -40,6 +42,18 @@ data class UserDb(
|
|||||||
throw NotAuth()
|
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)
|
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? {
|
fun findUserByEmail(email: String): UserDb? {
|
||||||
var users =
|
var users = db.query("select * from users where email=?", arrayOf(email), UserDb).toList()
|
||||||
db
|
|
||||||
.query("select * from users where email=?", arrayOf(email)) { response, _ ->
|
if (users.size == 0) {
|
||||||
UserDb(
|
return null
|
||||||
response.getString("id"),
|
}
|
||||||
response.getString("username"),
|
|
||||||
response.getString("email"),
|
return users[0]
|
||||||
response.getString("passwd"),
|
}
|
||||||
response.getInt("level")
|
|
||||||
)
|
fun findUserById(id: String): UserDb? {
|
||||||
}
|
var users = db.query("select * from users where id=?", arrayOf(id), UserDb).toList()
|
||||||
.toList()
|
|
||||||
|
|
||||||
if (users.size == 0) {
|
if (users.size == 0) {
|
||||||
return null
|
return null
|
||||||
@ -166,7 +179,7 @@ class SessionService(val db: JdbcTemplate) {
|
|||||||
|
|
||||||
fun verifyToken(token: String?): UserDb? {
|
fun verifyToken(token: String?): UserDb? {
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
var users =
|
var users =
|
||||||
db
|
db
|
||||||
|
@ -18,6 +18,7 @@ create table if not exists applications (
|
|||||||
mesasge text default '',
|
mesasge text default '',
|
||||||
user_id text,
|
user_id text,
|
||||||
extra_data text,
|
extra_data text,
|
||||||
|
job_level text default '',
|
||||||
status_id text default null,
|
status_id text default null,
|
||||||
agency boolean default false,
|
agency boolean default false,
|
||||||
create_time timestamp default now ()
|
create_time timestamp default now ()
|
||||||
|
@ -84,7 +84,6 @@ browser.runtime.onInstalled.addListener(startup);
|
|||||||
browser.runtime.onStartup.addListener(startup);
|
browser.runtime.onStartup.addListener(startup);
|
||||||
browser.runtime.onConnect.addListener(startup);
|
browser.runtime.onConnect.addListener(startup);
|
||||||
browser.menus.onClicked.addListener(async function (e, tab) {
|
browser.menus.onClicked.addListener(async function (e, tab) {
|
||||||
console.log("here")
|
|
||||||
if (e.menuItemId === "mark-page") {
|
if (e.menuItemId === "mark-page") {
|
||||||
console.log("set mark-page", tab.id)
|
console.log("set mark-page", tab.id)
|
||||||
await browser.storage.local.set({
|
await browser.storage.local.set({
|
||||||
|
@ -9,7 +9,12 @@ browser.runtime.onMessage.addListener((message) => {
|
|||||||
}
|
}
|
||||||
const jobTitle = document.querySelector('h1').textContent;
|
const jobTitle = document.querySelector('h1').textContent;
|
||||||
const company = document.querySelector('.relative a[target="_self"]').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;
|
const description = document.querySelector('article').textContent;
|
||||||
|
|
||||||
browser.runtime.sendMessage({ type: "GOT_INFO_R", company, jobTitle, money, description });
|
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;
|
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');
|
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 ?? ''
|
money = moneySectionNode.querySelector("div>div>div").children[1]?.textContent ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
118
site/src/app.css
118
site/src/app.css
@ -3,87 +3,87 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrainsMono';
|
font-family: 'JetBrainsMono';
|
||||||
src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf');
|
src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf');
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.grad-back {
|
.grad-back {
|
||||||
@apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700;
|
@apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700;
|
||||||
background-size: 100vw 400vh;
|
background-size: 100vw 400vh;
|
||||||
animation: grad-back 100s linear infinite;
|
animation: grad-back 100s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes grad-back {
|
@keyframes grad-back {
|
||||||
from {
|
from {
|
||||||
background-position: 0 0;
|
background-position: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
background-position: 0 400vh;
|
background-position: 0 400vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-JetBrainsMono {
|
.font-JetBrainsMono {
|
||||||
font-family: 'JetBrainsMono';
|
font-family: 'JetBrainsMono';
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@apply text-purple-500 font-bold font-JetBrainsMono text-xl;
|
@apply text-purple-500 font-bold font-JetBrainsMono text-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
@apply p-3 rounded-md;
|
@apply p-3 rounded-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
@apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0;
|
@apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flabel {
|
.flabel {
|
||||||
@apply block text-purple-500;
|
@apply block text-purple-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finput {
|
.finput {
|
||||||
@apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1;
|
@apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1 bg-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finput[type='color'] {
|
.finput[type='color'] {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btns {
|
.btns {
|
||||||
@apply flex justify-center py-2 gap-2;
|
@apply flex justify-center py-2 gap-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply rounded-lg text-white bg-violet-500 p-2;
|
@apply rounded-lg text-white bg-violet-500 p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply rounded-lg bg-danger p-2 text-white;
|
@apply rounded-lg bg-danger p-2 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm {
|
.btn-confirm {
|
||||||
@apply rounded-lg bg-blue-400 text-white p-2;
|
@apply rounded-lg bg-blue-400 text-white p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white rounded-lg drop-shadow-lg;
|
@apply bg-white rounded-lg drop-shadow-lg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@print {
|
@print {
|
||||||
@page :footer {
|
@page :footer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page :header {
|
@page :header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: auto;
|
size: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,7 @@ export type Application = {
|
|||||||
linked_application: string;
|
linked_application: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
status_history: string;
|
status_history: string;
|
||||||
|
job_level: string;
|
||||||
flairs: Flair[];
|
flairs: Flair[];
|
||||||
events: ApplicationEvent[];
|
events: ApplicationEvent[];
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let filter = $state('');
|
let filter = $state('');
|
||||||
@ -28,15 +29,57 @@
|
|||||||
return x.match(f);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0">
|
<div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0">
|
||||||
<h1>To Apply</h1>
|
<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} />
|
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
|
||||||
<div>
|
<div>
|
||||||
{internal.length}
|
{internal.length}
|
||||||
</div>
|
</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>
|
||||||
<div class="overflow-auto flex-grow p-2">
|
<div class="overflow-auto flex-grow p-2">
|
||||||
{#each internal as item}
|
{#each internal as item}
|
||||||
@ -51,7 +94,10 @@
|
|||||||
}}
|
}}
|
||||||
role="none"
|
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">
|
<h2 class="text-lg text-blue-500 flex gap-2 max-w-full overflow-hidden">
|
||||||
<div class="flex-grow max-w-[90%]">
|
<div class="flex-grow max-w-[90%]">
|
||||||
<div class="whitespace-nowrap overflow-hidden">
|
<div class="whitespace-nowrap overflow-hidden">
|
||||||
|
555
site/src/routes/graphs/+page.svelte
Normal file
555
site/src/routes/graphs/+page.svelte
Normal 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>
|
172
site/src/routes/graphs/Pie.svelte
Normal file
172
site/src/routes/graphs/Pie.svelte
Normal 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>
|
@ -51,7 +51,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if applicationStore.dragging && derivedItem}
|
{#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}
|
{#each statusStore.dirLinks[derivedItem.status_id] as node}
|
||||||
<DropZone icon={node.icon} ondrop={() => moveStatus(node.id, node.endable)}
|
<DropZone icon={node.icon} ondrop={() => moveStatus(node.id, node.endable)}
|
||||||
>{node.name}</DropZone
|
>{node.name}</DropZone
|
||||||
|
@ -3,7 +3,8 @@
|
|||||||
import { preventDefault } from '$lib/utils';
|
import { preventDefault } from '$lib/utils';
|
||||||
import type { Snippet } from 'svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -13,9 +14,7 @@
|
|||||||
ondragenter={preventDefault(() => {})}
|
ondragenter={preventDefault(() => {})}
|
||||||
{ondrop}
|
{ondrop}
|
||||||
>
|
>
|
||||||
<span
|
<span class="bi bi-{icon} text-7xl absolute" class:animate-bounce={applicationStore.dragging}
|
||||||
class="bi bi-{icon} text-7xl absolute"
|
|
||||||
class:animate-bounce={applicationStore.dragging}
|
|
||||||
></span>
|
></span>
|
||||||
<span class="bi bi-{icon} text-7xl opacity-0"></span>
|
<span class="bi bi-{icon} text-7xl opacity-0"></span>
|
||||||
<div class="text-xl">{@render children()}</div>
|
<div class="text-xl">{@render children()}</div>
|
||||||
|
@ -13,7 +13,11 @@
|
|||||||
onreload: (item: Application) => void;
|
onreload: (item: Application) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let filter = $state('');
|
let filter = $state(application.company ? `@ ${application.company}` : '');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
filter = application.company ? `@ ${application.company}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
async function submit(item: Application) {
|
async function submit(item: Application) {
|
||||||
try {
|
try {
|
||||||
@ -29,6 +33,7 @@
|
|||||||
|
|
||||||
let internal = $derived(
|
let internal = $derived(
|
||||||
applicationStore.all.filter((i) => {
|
applicationStore.all.filter((i) => {
|
||||||
|
if (i.id === application.id) return false;
|
||||||
if (!filter) {
|
if (!filter) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -46,9 +51,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog class="card max-w-[50vw]" bind:this={dialog}>
|
<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} />
|
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
|
||||||
<div>
|
<div class="p-2">
|
||||||
{internal.length}
|
{internal.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -83,7 +83,18 @@
|
|||||||
<form bind:this={form} onsubmit={preventDefault(submit)}>
|
<form bind:this={form} onsubmit={preventDefault(submit)}>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label class="flabel" for="text">Url</label>
|
<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>
|
</fieldset>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button type="submit" class="btn-confirm">Update</button>
|
<button type="submit" class="btn-confirm">Update</button>
|
||||||
|
@ -60,6 +60,12 @@
|
|||||||
lastEvent = event;
|
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
|
// Todo endable
|
||||||
/*
|
/*
|
||||||
if (_events.length > 0 && !endable.includes(_events[_events.length - 1].new_status_id)) {
|
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"
|
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}
|
{#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>
|
></span>
|
||||||
{:else if event.event_type == EventType.View}
|
{: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>
|
></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
@ -102,7 +112,9 @@
|
|||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
<!-- || !endable.includes(event.new_status) -->
|
<!-- || !endable.includes(event.new_status) -->
|
||||||
{#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable}
|
{#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}
|
{#if event.timeDiff}
|
||||||
<div class="-mt-[3px] text-white">
|
<div class="-mt-[3px] text-white">
|
||||||
<span class="bi bi-clock"></span>
|
<span class="bi bi-clock"></span>
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
import CompanyField from './CompanyField.svelte';
|
import CompanyField from './CompanyField.svelte';
|
||||||
import AutoDropZone from './AutoDropZone.svelte';
|
import AutoDropZone from './AutoDropZone.svelte';
|
||||||
import { statusStore } from '$lib/Types.svelte';
|
import { statusStore } from '$lib/Types.svelte';
|
||||||
import { thresholdFreedmanDiaconis } from 'd3';
|
|
||||||
|
|
||||||
// Not this represents the index in the store array
|
// Not this represents the index in the store array
|
||||||
let activeItem: number | undefined = $state();
|
let activeItem: number | undefined = $state();
|
||||||
@ -124,9 +123,27 @@
|
|||||||
|
|
||||||
function setExtData() {
|
function setExtData() {
|
||||||
if (!lastExtData || activeItem === undefined || !derivedItem) return;
|
if (!lastExtData || activeItem === undefined || !derivedItem) return;
|
||||||
applicationStore.all[activeItem].title = lastExtData.jobTitle;
|
applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&/, '&');
|
||||||
applicationStore.all[activeItem].company = lastExtData.company;
|
applicationStore.all[activeItem].company = lastExtData.company.replace(/\&/, '&');
|
||||||
applicationStore.all[activeItem].payrange = lastExtData.money;
|
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(() => {
|
window.requestAnimationFrame(() => {
|
||||||
save().then(async () => {
|
save().then(async () => {
|
||||||
if (activeItem === undefined) return;
|
if (activeItem === undefined) return;
|
||||||
@ -274,6 +291,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{/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>
|
||||||
<div class="flabel">Tags</div>
|
<div class="flabel">Tags</div>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
Loading…
Reference in New Issue
Block a user