Compare commits
25 commits
Author | SHA1 | Date | |
---|---|---|---|
b2ce85b146 | |||
649f074d25 | |||
71d62ecaf6 | |||
4f90a4c8bb | |||
8a530551c2 | |||
9e0f5af2e6 | |||
831e91a45d | |||
446691719f | |||
1877f23a6e | |||
b92ab4e437 | |||
d0d03f48b7 | |||
93da4f6648 | |||
c53978cd91 | |||
90638f5569 | |||
050748d8cd | |||
82bdc231e3 | |||
2739e2fd28 | |||
2a6cc07624 | |||
2c5c7332be | |||
54d5fa1831 | |||
36acb1a324 | |||
2c57c3c278 | |||
b9dbdb2c61 | |||
cee274318d | |||
69c3165fc1 |
36 changed files with 1717 additions and 519 deletions
|
@ -1,5 +0,0 @@
|
||||||
*
|
|
||||||
!*.go
|
|
||||||
!templates/*
|
|
||||||
!go.mod
|
|
||||||
!go.sum
|
|
37
.forgejo/workflows/workflow.yaml
Normal file
37
.forgejo/workflows/workflow.yaml
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
SPRUCE_TEST_VERBOSE: "1"
|
||||||
|
SPRUCE_TEST_COVER: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
|
- name: Setup Go
|
||||||
|
uses: https://code.forgejo.org/actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
- name: Test
|
||||||
|
run: go run magefiles/main.go -v test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
|
- name: Setup Go
|
||||||
|
uses: https://code.forgejo.org/actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
- name: Lint
|
||||||
|
uses: https://github.com/golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
version: v1.54
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -43,4 +43,5 @@ tags
|
||||||
[._]*.un~
|
[._]*.un~
|
||||||
|
|
||||||
cv.pdf
|
cv.pdf
|
||||||
spruce
|
cv.json
|
||||||
|
/spruce
|
||||||
|
|
55
.golangci.yml
Normal file
55
.golangci.yml
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
run:
|
||||||
|
concurrency: 2
|
||||||
|
timeout: 1m
|
||||||
|
issues-exit-code: 1
|
||||||
|
tests: true
|
||||||
|
modules-download-mode: readonly
|
||||||
|
|
||||||
|
output:
|
||||||
|
format: colored-line-number
|
||||||
|
print-issues-lines: true
|
||||||
|
print-linter-name: true
|
||||||
|
uniq-by-line: true
|
||||||
|
sort-results: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- exhaustruct
|
||||||
|
- exhaustivestruct # (deprecated)
|
||||||
|
- golint # (deprecated)
|
||||||
|
- ifshort # (deprecated)
|
||||||
|
- scopelint # (deprecated)
|
||||||
|
- maligned # (deprecated)
|
||||||
|
- deadcode # (deprecated)
|
||||||
|
- nosnakecase # (deprecated)
|
||||||
|
- interfacer # (deprecated)
|
||||||
|
- varcheck # (deprecated)
|
||||||
|
- structcheck # (deprecated)
|
||||||
|
fast: false
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
main:
|
||||||
|
files:
|
||||||
|
- $all
|
||||||
|
allow:
|
||||||
|
- $gostd
|
||||||
|
- codeflow.dananglin.me.uk/apollo/spruce
|
||||||
|
lll:
|
||||||
|
line-length: 140
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: cmd/spruce/main.go
|
||||||
|
linters:
|
||||||
|
- gochecknoglobals
|
||||||
|
- path: cmd/spruce-docgen/main.go
|
||||||
|
linters:
|
||||||
|
- gochecknoglobals
|
||||||
|
- cyclop
|
||||||
|
- path: internal/cmd/generate.go
|
||||||
|
linters:
|
||||||
|
- gomnd
|
|
@ -1,22 +1,32 @@
|
||||||
FROM golang:1.19-buster AS spruce-builder
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
ENV GOOS=linux
|
ENV GOOS=linux
|
||||||
ENV GOARCH=amd64
|
ENV GOARCH=amd64
|
||||||
|
|
||||||
|
WORKDIR /tmp
|
||||||
|
|
||||||
|
RUN apk add --no-cache git \
|
||||||
|
&& git clone https://github.com/magefile/mage
|
||||||
|
|
||||||
|
WORKDIR /tmp/mage
|
||||||
|
|
||||||
|
RUN go run bootstrap.go
|
||||||
|
|
||||||
COPY . /workspace
|
COPY . /workspace
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
RUN go build -a -v -o /workspace/spruce
|
RUN mage build
|
||||||
|
|
||||||
FROM alpine:3.17
|
FROM alpine:3.18
|
||||||
|
|
||||||
COPY --from=spruce-builder /workspace/spruce /usr/local/bin
|
COPY --from=builder /workspace/spruce /usr/local/bin
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN apk upgrade --no-cache \
|
RUN apk upgrade --no-cache \
|
||||||
&& apk add \
|
&& apk add --no-cache \
|
||||||
font-carlito \
|
font-carlito \
|
||||||
aspell \
|
aspell \
|
||||||
curl \
|
curl \
|
||||||
|
@ -33,3 +43,5 @@ RUN curl -LO http://lmtx.pragma-ade.com/install-lmtx/context-linuxmusl.zip \
|
||||||
ENV PATH=${PATH}:/opt/context/tex/texmf-linuxmusl/bin
|
ENV PATH=${PATH}:/opt/context/tex/texmf-linuxmusl/bin
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["spruce"]
|
21
Makefile
21
Makefile
|
@ -1,21 +0,0 @@
|
||||||
BINARY = spruce
|
|
||||||
INSTALL_PREFIX ?= /usr/local
|
|
||||||
CGO_ENABLED ?= 0
|
|
||||||
GOOS ?= linux
|
|
||||||
GOARCH ?= amd64
|
|
||||||
VERSION = 0.1.0
|
|
||||||
|
|
||||||
LDFLAGS = "-s -w -X main.version=$(VERSION) -X main.installPrefix=$(INSTALL_PREFIX)"
|
|
||||||
|
|
||||||
$(BINARY):
|
|
||||||
go build -ldflags=$(LDFLAGS) -v -a -o $(BINARY)
|
|
||||||
|
|
||||||
install: spruce
|
|
||||||
cp -f $(BINARY) $(INSTALL_PREFIX)/bin
|
|
||||||
chmod 0755 $(INSTALL_PREFIX)/bin/$(BINARY)
|
|
||||||
|
|
||||||
uninstall:
|
|
||||||
rm -f $(INSTALL_PREFIX)/bin/$(BINARY)
|
|
||||||
|
|
||||||
clean:
|
|
||||||
go clean
|
|
210
README.asciidoc
Normal file
210
README.asciidoc
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
= Spruce - A tool for building CVs
|
||||||
|
:toc:
|
||||||
|
:toclevels: 1
|
||||||
|
:toc-title: Contents
|
||||||
|
|
||||||
|
== Overview
|
||||||
|
|
||||||
|
Spruce is a tool that generates a PDF document from a CV written in JSON.
|
||||||
|
The PDF generation is completed in two steps:
|
||||||
|
|
||||||
|
1. The application parses the JSON document and generates a TEX file using the TEX template files located in the https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/internal/cmd/templates/tex[templates] directory.
|
||||||
|
2. The application then uses ConTeXt to generate the final PDF document from the generated TEX file.
|
||||||
|
|
||||||
|
== Requirements
|
||||||
|
|
||||||
|
Below is a list of required tools for installing and using spruce.
|
||||||
|
|
||||||
|
=== Go
|
||||||
|
|
||||||
|
A minimum version of Go 1.21.0 is required for installing spruce.
|
||||||
|
Please go https://go.dev/dl/[here] to download the latest version.
|
||||||
|
|
||||||
|
=== ConTeXt
|
||||||
|
|
||||||
|
ConTeXt is required for generating the PDF documentation.
|
||||||
|
You can go to the https://wiki.contextgarden.net/Installation[installation page] to find out how
|
||||||
|
to install ConTeXt for your Operating System.
|
||||||
|
|
||||||
|
=== The Carlito font (ttf-carlito)
|
||||||
|
|
||||||
|
Carlito is a free, metric compatible alternative to the Calibri font from Microsoft and is used when generating the PDF documentation.
|
||||||
|
|
||||||
|
For Debian/Ubuntu distributions you can use `apt` to install the font.
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
apt install font-crosextra-carlito
|
||||||
|
----
|
||||||
|
|
||||||
|
For Arch Linux you can use `pacman`.
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
pacman -S ttf-carlito
|
||||||
|
----
|
||||||
|
|
||||||
|
Alternatively you can download the font from https://fontlibrary.org/en/font/carlito[Font Library].
|
||||||
|
|
||||||
|
Once Carlito is installed you'll need to update ConTeXt so it can find the font when generating the PDF:
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
OSFONTDIR=/usr/share/fonts
|
||||||
|
mtxrun --script fonts --reload
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Mage (Optional)
|
||||||
|
|
||||||
|
The project includes a https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/magefiles/mage.go[magefile] for automating the build and installation of the spruce binary.
|
||||||
|
With Mage the build information is built into the binary when it is compiled.
|
||||||
|
You can visit the https://magefile.org[website] for instructions on how to install Mage.
|
||||||
|
|
||||||
|
=== Docker (Optional)
|
||||||
|
|
||||||
|
You can use Docker to build and use a docker image built with spruce and ConText installed.
|
||||||
|
This could be useful for those who are comfortable using Docker and don't want to install the above dependencies
|
||||||
|
directly onto their machines.
|
||||||
|
|
||||||
|
== Installation
|
||||||
|
|
||||||
|
=== With Mage
|
||||||
|
|
||||||
|
You can install spruce with Mage using the following commands:
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
git clone https://codeflow.dananglin.me.uk/apollo/spruce.git
|
||||||
|
cd spruce
|
||||||
|
mage install
|
||||||
|
----
|
||||||
|
|
||||||
|
The default install prefix is set to `/usr/local` so spruce will be installed to `/usr/local/bin/spruce`.
|
||||||
|
If you don't have sudo privileges or you want to change the install prefix you can set the `SPRUCE_INSTALL_PREFIX` environment variable before installing.
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
SPRUCE_INSTALL_PREFIX=~/.local mage install
|
||||||
|
----
|
||||||
|
|
||||||
|
=== With Go
|
||||||
|
|
||||||
|
If your `GOBIN` directory is included in your `PATH` then you can install spruce with Go.
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
git clone https://codeflow.dananglin.me.uk/apollo/spruce.git
|
||||||
|
cd spruce
|
||||||
|
go install ./cmd/spruce
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Building the Docker image
|
||||||
|
|
||||||
|
You can build a docker image using the Dockerfile included in this project.
|
||||||
|
The build will build and install the spruce binary as well as install all the required dependencies in the final image.
|
||||||
|
You can build the docker image with the following command:
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
docker build -t spruce .
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Verifying the installation
|
||||||
|
|
||||||
|
Run `spruce` to verify your installation. You should see the usage printed onto your screen.
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
$ spruce
|
||||||
|
SUMMARY:
|
||||||
|
spruce - A command-line tool for building CVs
|
||||||
|
|
||||||
|
VERSION:
|
||||||
|
v0.3.0
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
spruce [flags]
|
||||||
|
spruce [command]
|
||||||
|
|
||||||
|
COMMANDS:
|
||||||
|
create creates a new CV JSON file
|
||||||
|
generate generates a PDF file from an existing CV JSON file
|
||||||
|
version print the application's version and build information
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
-help, --help
|
||||||
|
print the help message
|
||||||
|
|
||||||
|
Use "spruce [command] --help" for more information about a command.
|
||||||
|
----
|
||||||
|
|
||||||
|
If you've build the docker image you can get the same help message with the following command:
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
docker run --rm -it spruce
|
||||||
|
----
|
||||||
|
|
||||||
|
If you have installed spruce with Mage, you can get the build information to confirm that you have installed the correct version.
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
$ spruce version --full
|
||||||
|
Spruce
|
||||||
|
Version: v0.1.0-14-ge503dbf
|
||||||
|
Git commit: e503dbf
|
||||||
|
Go version: go1.21.0
|
||||||
|
Build date: 2023-08-12T13:00:51Z
|
||||||
|
----
|
||||||
|
|
||||||
|
If you've built the docker image you can verify it with the following command:
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
docker run --rm spruce version --full
|
||||||
|
----
|
||||||
|
|
||||||
|
== Generating the example PDF Document
|
||||||
|
|
||||||
|
Once you've installed spruce you can generate a PDF file from the example CV by running the following command:
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
spruce generate --input example/cv.json
|
||||||
|
----
|
||||||
|
|
||||||
|
This will create a file called `cv.pdf` which you can view with your favourite PDF viewer.
|
||||||
|
|
||||||
|
If you're using the docker image you can generate the PDF file using the following command:
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
docker run --rm -v ./example:/workspace spruce generate --input cv.json --output cv.pdf
|
||||||
|
----
|
||||||
|
|
||||||
|
The PDF file will be created in the `example` folder.
|
||||||
|
|
||||||
|
== Creating your own CV
|
||||||
|
|
||||||
|
To create your own CV run `spruce create`.
|
||||||
|
You can use additional flags to populate the CV with basic details such as your first name, last name and current job title.
|
||||||
|
Run `spruce create --help` to see all available flags.
|
||||||
|
After executing this command you'll find a file called **cv.json** which will contain the skeleton of your CV JSON document.
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
$ spruce create
|
||||||
|
│time=2023-08-18T13:21:10.120+01:00 level=INFO msg="CV successfully created" filename=cv.json
|
||||||
|
----
|
||||||
|
|
||||||
|
You can now start populating the fields with your favourite text editor.
|
||||||
|
Please refer to https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/docs/schema.asciidoc[the schema reference] for more information on each field.
|
||||||
|
You can also check out the https://codeflow.dananglin.me.uk/apollo/spruce/src/branch/main/example/cv.json[example CV] as an additional guide.
|
||||||
|
|
||||||
|
Once you're happy with your CV you can run `spruce generate` to generate the PDF documentation.
|
||||||
|
|
||||||
|
[source,console]
|
||||||
|
----
|
||||||
|
$ spruce generate
|
||||||
|
time=2023-08-18T13:32:03.240+01:00 level=INFO msg="Creating the Tex file."
|
||||||
|
time=2023-08-18T13:32:03.252+01:00 level=INFO msg="Tex file successfully created." filename=/tmp/cv-builder-1016910977/cv.tex
|
||||||
|
time=2023-08-18T13:32:03.252+01:00 level=INFO msg="Creating the PDF document."
|
||||||
|
time=2023-08-18T13:32:06.416+01:00 level=INFO msg="PDF document successfully created." filename=/tmp/cv-builder-1016910977/cv.pdf
|
||||||
|
time=2023-08-18T13:32:06.416+01:00 level=INFO msg="File successfully copied." source=/tmp/cv-builder-1016910977/cv.pdf destination=cv.pdf
|
||||||
|
----
|
||||||
|
|
||||||
|
== Inspirations
|
||||||
|
|
||||||
|
- https://mszep.github.io/pandoc_resume/[The Markdown Resume:] This project uses ConTeXt and pandoc to convert Markdown based CVs into multiple formats including PDF, HTML and DOCX. This is where I discovered ConTeXt.
|
||||||
|
- https://github.com/melkir/resume/tree/master[melkir/resume:] This project generates CVs using Go and LaTeX.
|
46
README.md
46
README.md
|
@ -1,46 +0,0 @@
|
||||||
# Spruce - A tool for building CVs
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Spruce is a tool that generates a PDF document from a CV written in JSON.
|
|
||||||
The PDF generation is completed in two steps:
|
|
||||||
|
|
||||||
1. The application parses the JSON document and generates a TEX file using the TEX template files located in the [templates](./templates) directory.
|
|
||||||
2. The application then uses ConTeXt to generate the final PDF document from the generated TEX file.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
If you are interested in generating your own CV, then below is a list of dependencies that you'll need to install:
|
|
||||||
|
|
||||||
- **Go:** Please go [here](https://go.dev/dl/) to download the latest version of the Go programming language.
|
|
||||||
- **ConTeXt:** You can go to the [installation page](https://wiki.contextgarden.net/Installation) to find out how to install ConTeXt for your Operating System.
|
|
||||||
- **The Carlito font (ttf-carlito):** A free, metric compatible alternative to the Calibri.
|
|
||||||
- For Ubuntu/Debian installation you can use `apt`:
|
|
||||||
```bash
|
|
||||||
$ apt install font-crosextra-carlito
|
|
||||||
```
|
|
||||||
- For Arch Linux you can use `pacman`:
|
|
||||||
```bash
|
|
||||||
$ pacman -S ttf-carlito
|
|
||||||
```
|
|
||||||
- Alternatively you can download the font from https://fontlibrary.org/en/font/carlito
|
|
||||||
- Once this font is installed you'll need to update ConTeXt so it can find the font when generating the PDF:
|
|
||||||
```bash
|
|
||||||
$ OSFONTDIR=/usr/share/fonts
|
|
||||||
$ mtxrun --script fonts --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generating the example PDF Document
|
|
||||||
|
|
||||||
You can generate a PDF from the example CV by running the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ go run . --input example/cv.json
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create a file called `cv.pdf` which you can view in your favourite PDF viewer.
|
|
||||||
|
|
||||||
## Inspirations
|
|
||||||
|
|
||||||
- [The Markdown Resume](https://mszep.github.io/pandoc_resume/) - This project uses ConTeXt and pandoc to convert Markdown based CVs into multiple formats including PDF, HTML and DOCX. This is where I discovered ConTeXt.
|
|
||||||
- [melkir/resume](https://github.com/melkir/resume/tree/master) - This project generates CVs using Go and LaTeX.
|
|
165
cmd/spruce-docgen/main.go
Normal file
165
cmd/spruce-docgen/main.go
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var schemaReferenceTemplate = `= JSON schema reference
|
||||||
|
|
||||||
|
NOTE: This page was auto-generated with spruce-docgen.
|
||||||
|
|
||||||
|
== {{ .Title }}
|
||||||
|
|
||||||
|
{{ .Description }}
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
{{- range $key, $property := .Properties }}
|
||||||
|
{{ print "" }}
|
||||||
|
|{{ $key }}
|
||||||
|
|{{ type $property }}
|
||||||
|
|{{ $property.Description }}
|
||||||
|
{{- end -}}{{ print "" }}
|
||||||
|
|===
|
||||||
|
{{ print "" }}
|
||||||
|
{{- range $i, $schema := .Defs }}
|
||||||
|
=== {{ capitalise $i }}
|
||||||
|
{{ print "" }}
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
{{- range $key, $property := $schema.Properties }}
|
||||||
|
{{ print "" }}
|
||||||
|
|{{ $key }}
|
||||||
|
|{{ type $property }}
|
||||||
|
|{{ $property.Description }}
|
||||||
|
{{- end -}}{{ print "" }}
|
||||||
|
|===
|
||||||
|
{{ print "" }}
|
||||||
|
{{- end }}
|
||||||
|
`
|
||||||
|
|
||||||
|
// schema minimally represents the JSON schema format.
|
||||||
|
type schema struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties map[string]*schema `json:"properties"`
|
||||||
|
Items *schema `json:"items"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
Ref string `json:"$ref"`
|
||||||
|
Defs map[string]*schema `json:"$defs"`
|
||||||
|
AdditionalProperties *schema `json:"additionalProperties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *schema) UnmarshalJSON(data []byte) error {
|
||||||
|
if bytes.Equal(data, []byte("true")) || bytes.Equal(data, []byte("false")) {
|
||||||
|
*s = schema{}
|
||||||
|
} else {
|
||||||
|
type rawSchema schema
|
||||||
|
var res rawSchema
|
||||||
|
if err := json.Unmarshal(data, &res); err != nil {
|
||||||
|
return fmt.Errorf("unable to unmarshal to rawSchema; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = schema(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
schemaFile := "./schema/cv.schema.json"
|
||||||
|
|
||||||
|
file, err := os.Open(schemaFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
|
||||||
|
var data schema
|
||||||
|
|
||||||
|
if err := decoder.Decode(&data); err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"capitalise": title,
|
||||||
|
"type": getType,
|
||||||
|
}
|
||||||
|
|
||||||
|
t := template.Must(template.New("asciidoc").Funcs(funcMap).Parse(schemaReferenceTemplate))
|
||||||
|
|
||||||
|
if err = t.Execute(os.Stdout, data); err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func title(str string) string {
|
||||||
|
runes := []rune(str)
|
||||||
|
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
typeArray = "array"
|
||||||
|
typeObject = "object"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getType(data schema) string {
|
||||||
|
if data.Type != "" && data.Type != typeArray && data.Type != typeObject {
|
||||||
|
return data.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Type == typeArray {
|
||||||
|
switch {
|
||||||
|
case data.Items == nil:
|
||||||
|
return "list(UNKNOWN)"
|
||||||
|
case data.Items.Type != "":
|
||||||
|
return "list(" + data.Items.Type + ")"
|
||||||
|
case data.Items.Ref != "":
|
||||||
|
return "list(<<" + title(refType(data.Items.Ref)) + ">>)"
|
||||||
|
default:
|
||||||
|
return "list(UNKNOWN)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Type == "" && data.Ref != "" {
|
||||||
|
return "<<" + title(refType(data.Ref)) + ">>"
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Type == typeObject {
|
||||||
|
if data.AdditionalProperties.Type != "" {
|
||||||
|
return "map(" + data.AdditionalProperties.Type + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeObject
|
||||||
|
}
|
||||||
|
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func refType(str string) string {
|
||||||
|
prefix := "#/$defs/"
|
||||||
|
if !strings.HasPrefix(str, prefix) {
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimPrefix(str, prefix)
|
||||||
|
}
|
123
cmd/spruce/main.go
Normal file
123
cmd/spruce/main.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeflow.dananglin.me.uk/apollo/spruce/internal/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
create string = "create"
|
||||||
|
generate string = "generate"
|
||||||
|
version string = "version"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
binaryVersion string
|
||||||
|
buildTime string
|
||||||
|
goVersion string
|
||||||
|
gitCommit string
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
summaries := map[string]string{
|
||||||
|
create: "creates a new CV JSON file",
|
||||||
|
generate: "generates a PDF file from an existing CV JSON file",
|
||||||
|
version: "print the application's version and build information",
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Usage = spruceUsageFunc(summaries)
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if flag.NArg() < 1 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommand := flag.Arg(0)
|
||||||
|
args := flag.Args()[1:]
|
||||||
|
|
||||||
|
logOptions := slog.HandlerOptions{
|
||||||
|
AddSource: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &logOptions))
|
||||||
|
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
var runner cmd.Runner
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case create:
|
||||||
|
runner = cmd.NewCreateCommand(create, summaries[create])
|
||||||
|
case generate:
|
||||||
|
runner = cmd.NewGenerateCommand(generate, summaries[generate])
|
||||||
|
case version:
|
||||||
|
runner = cmd.NewVersionCommand(
|
||||||
|
binaryVersion,
|
||||||
|
buildTime,
|
||||||
|
goVersion,
|
||||||
|
gitCommit,
|
||||||
|
version,
|
||||||
|
summaries[version],
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
slog.Error("unknown subcommand", "subcommand", subcommand)
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.Parse(args); err != nil {
|
||||||
|
slog.Error(fmt.Sprintf("unable to parse the command line flags; %v.", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.Run(); err != nil {
|
||||||
|
slog.Error(fmt.Sprintf("unable to run %q; %v.", runner.Name(), err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spruceUsageFunc(summaries map[string]string) func() {
|
||||||
|
cmds := make([]string, len(summaries))
|
||||||
|
ind := 0
|
||||||
|
|
||||||
|
for k := range summaries {
|
||||||
|
cmds[ind] = k
|
||||||
|
ind++
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(cmds)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.WriteString("SUMMARY:\n spruce - A command-line tool for building CVs\n\n")
|
||||||
|
|
||||||
|
if binaryVersion != "" {
|
||||||
|
builder.WriteString("VERSION:\n " + binaryVersion + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString("USAGE:\n spruce [flags]\n spruce [command]\n\nCOMMANDS:")
|
||||||
|
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd])
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n")
|
||||||
|
flag.VisitAll(func(f *flag.Flag) {
|
||||||
|
fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage)
|
||||||
|
})
|
||||||
|
|
||||||
|
builder.WriteString("\nUse \"spruce [command] --help\" for more information about a command.\n")
|
||||||
|
|
||||||
|
w := flag.CommandLine.Output()
|
||||||
|
fmt.Fprint(w, builder.String())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,55 +0,0 @@
|
||||||
//go:build dagger
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"dagger.io/dagger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := build(context.Background()); err != nil {
|
|
||||||
log.Printf("ERROR: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func build(ctx context.Context) error {
|
|
||||||
log.Println("Building with Dagger.")
|
|
||||||
|
|
||||||
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create the dagger client; %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
rootDir, err := filepath.Abs(".")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
image := "golang:1.20"
|
|
||||||
|
|
||||||
containerWorkspace := "/workspace"
|
|
||||||
|
|
||||||
builder := client.Container().
|
|
||||||
From(image).
|
|
||||||
WithMountedDirectory(containerWorkspace, client.Host().Directory(rootDir)).
|
|
||||||
WithWorkdir(containerWorkspace).
|
|
||||||
WithEnvVariable("CGO_ENABLED", "0").
|
|
||||||
WithEnvVariable("GOOS", "linux").
|
|
||||||
WithEnvVariable("GOARCH", "amd64").
|
|
||||||
WithExec([]string{"make", "clean", "spruce"})
|
|
||||||
|
|
||||||
_, err = builder.Stdout(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to build the binary; %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
175
docs/schema.asciidoc
Normal file
175
docs/schema.asciidoc
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
= JSON schema reference
|
||||||
|
|
||||||
|
NOTE: This page was auto-generated with spruce-docgen.
|
||||||
|
|
||||||
|
== CV
|
||||||
|
|
||||||
|
A short written summary of your skills, achievements and experiences in relation to your role.
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|contact
|
||||||
|
|map(string)
|
||||||
|
|Your contact information. You can use any key/value pairs here.
|
||||||
|
|
||||||
|
|education
|
||||||
|
|list(<<Education>>)
|
||||||
|
|A list of your education experiences.
|
||||||
|
|
||||||
|
|employment
|
||||||
|
|list(<<Employment>>)
|
||||||
|
|A list of your employment history.
|
||||||
|
|
||||||
|
|firstName
|
||||||
|
|string
|
||||||
|
|Your first name(s).
|
||||||
|
|
||||||
|
|interests
|
||||||
|
|list(string)
|
||||||
|
|A list of sentences summarising your interests and hobbies.
|
||||||
|
|
||||||
|
|jobTitle
|
||||||
|
|string
|
||||||
|
|Your current job title.
|
||||||
|
|
||||||
|
|lastName
|
||||||
|
|string
|
||||||
|
|Your last name.
|
||||||
|
|
||||||
|
|links
|
||||||
|
|map(string)
|
||||||
|
|URLs to your online presence such as GitHub or your website. You can use any key/value pairs here.
|
||||||
|
|
||||||
|
|skills
|
||||||
|
|list(<<Skills>>)
|
||||||
|
|A categorised list of your skills.
|
||||||
|
|
||||||
|
|summary
|
||||||
|
|list(string)
|
||||||
|
|A list of sentences summarising your skills, experiences and what you'd like to achieve in the near future.
|
||||||
|
|===
|
||||||
|
|
||||||
|
=== Date
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|day
|
||||||
|
|integer
|
||||||
|
|The day of the month.
|
||||||
|
|
||||||
|
|month
|
||||||
|
|integer
|
||||||
|
|The numerical value of the month (e.g. 5 for May).
|
||||||
|
|
||||||
|
|year
|
||||||
|
|integer
|
||||||
|
|The year (e.g. 2023).
|
||||||
|
|===
|
||||||
|
|
||||||
|
=== Duration
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|end
|
||||||
|
|<<Date>>
|
||||||
|
|The end date of the experience.
|
||||||
|
|
||||||
|
|present
|
||||||
|
|boolean
|
||||||
|
|Specifies whether you are currently employed or studying at the specified company or educational institute.
|
||||||
|
|
||||||
|
|start
|
||||||
|
|<<Date>>
|
||||||
|
|The start date of the experience.
|
||||||
|
|===
|
||||||
|
|
||||||
|
=== Education
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|details
|
||||||
|
|list(string)
|
||||||
|
|Further details of the experience.
|
||||||
|
|
||||||
|
|duration
|
||||||
|
|<<Duration>>
|
||||||
|
|The duration of the experience.
|
||||||
|
|
||||||
|
|location
|
||||||
|
|string
|
||||||
|
|The location where the experience was based.
|
||||||
|
|
||||||
|
|qualification
|
||||||
|
|string
|
||||||
|
|The qualifications gained from this educational experience.
|
||||||
|
|
||||||
|
|school
|
||||||
|
|string
|
||||||
|
|The school or university where you have studied.
|
||||||
|
|===
|
||||||
|
|
||||||
|
=== Employment
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|company
|
||||||
|
|string
|
||||||
|
|The company where your work experience took place.
|
||||||
|
|
||||||
|
|details
|
||||||
|
|list(string)
|
||||||
|
|Further details of the experience (e.g. achievements, daily responsibilities, etc).
|
||||||
|
|
||||||
|
|duration
|
||||||
|
|<<Duration>>
|
||||||
|
|The duration of the experience.
|
||||||
|
|
||||||
|
|jobTitle
|
||||||
|
|string
|
||||||
|
|The job title of your experience.
|
||||||
|
|
||||||
|
|location
|
||||||
|
|string
|
||||||
|
|The location where the experience was based.
|
||||||
|
|
||||||
|
|locationType
|
||||||
|
|string
|
||||||
|
|The location type of your work experience (e.g. Remote, Hybrid, On-site).
|
||||||
|
|===
|
||||||
|
|
||||||
|
=== Skills
|
||||||
|
|
||||||
|
[%header,cols=3*]
|
||||||
|
|===
|
||||||
|
|Field
|
||||||
|
|Type
|
||||||
|
|Description
|
||||||
|
|
||||||
|
|category
|
||||||
|
|string
|
||||||
|
|The skills category.
|
||||||
|
|
||||||
|
|values
|
||||||
|
|list(string)
|
||||||
|
|The skills listed in this category.
|
||||||
|
|===
|
|
@ -41,7 +41,8 @@
|
||||||
"employment": [
|
"employment": [
|
||||||
{
|
{
|
||||||
"company": "Company C",
|
"company": "Company C",
|
||||||
"location": "Manchester (Remote)",
|
"location": "Manchester",
|
||||||
|
"locationType": "Remote",
|
||||||
"jobTitle": "Software Engineer (Cloud Platform)",
|
"jobTitle": "Software Engineer (Cloud Platform)",
|
||||||
"duration": {
|
"duration": {
|
||||||
"start": {
|
"start": {
|
||||||
|
@ -59,7 +60,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Company B",
|
"company": "Company B",
|
||||||
"location": "London (Hybrid)",
|
"location": "London",
|
||||||
|
"locationType": "Hybrid",
|
||||||
"jobTitle": "Software Engineer (Backend)",
|
"jobTitle": "Software Engineer (Backend)",
|
||||||
"duration": {
|
"duration": {
|
||||||
"start": {
|
"start": {
|
||||||
|
@ -81,7 +83,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Company A",
|
"company": "Company A",
|
||||||
"location": "London (Onsite)",
|
"location": "London",
|
||||||
|
"locationType": "On-site",
|
||||||
"jobTitle": "Software Engineer (Backend)",
|
"jobTitle": "Software Engineer (Backend)",
|
||||||
"duration": {
|
"duration": {
|
||||||
"start": {
|
"start": {
|
||||||
|
@ -103,7 +106,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Engineering Department, Example University",
|
"company": "Engineering Department, Example University",
|
||||||
"location": "London (Onsite)",
|
"location": "London",
|
||||||
|
"locationType": "On-site",
|
||||||
"jobTitle": "Research and Development Assistant",
|
"jobTitle": "Research and Development Assistant",
|
||||||
"duration": {
|
"duration": {
|
||||||
"start": {
|
"start": {
|
||||||
|
|
13
go.mod
13
go.mod
|
@ -1,14 +1,5 @@
|
||||||
module codeflow.dananglin.me.uk/apollo/spruce
|
module codeflow.dananglin.me.uk/apollo/spruce
|
||||||
|
|
||||||
go 1.20
|
go 1.21
|
||||||
|
|
||||||
require dagger.io/dagger v0.5.0
|
require github.com/magefile/mage v1.15.0
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/Khan/genqlient v0.5.0 // indirect
|
|
||||||
github.com/adrg/xdg v0.4.0 // indirect
|
|
||||||
github.com/iancoleman/strcase v0.2.0 // indirect
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.1 // indirect
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
|
|
||||||
)
|
|
||||||
|
|
114
go.sum
114
go.sum
|
@ -1,112 +1,2 @@
|
||||||
dagger.io/dagger v0.5.0 h1:7hQnA/pFMpEkuaU2ScNWoZznTa6DbIqHnwBJwL5bBQY=
|
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||||
dagger.io/dagger v0.5.0/go.mod h1:1nbGnLdIfoBV2ahbQjheI//SNGz+b5q1jqf0A+pJ+Oc=
|
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
|
||||||
github.com/Khan/genqlient v0.5.0 h1:TMZJ+tl/BpbmGyIBiXzKzUftDhw4ZWxQZ+1ydn0gyII=
|
|
||||||
github.com/Khan/genqlient v0.5.0/go.mod h1:EpIvDVXYm01GP6AXzjA7dKriPTH6GmtpmvTAwUUqIX8=
|
|
||||||
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
|
|
||||||
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
|
|
||||||
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
|
||||||
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
|
||||||
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
|
|
||||||
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
|
||||||
github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM=
|
|
||||||
github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0=
|
|
||||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
|
||||||
github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
|
|
||||||
github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
|
|
||||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
|
||||||
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.4.5/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.1 h1:ZGu+bquAY23jsxDRcYpWjttRZrUz07LbiY77gUOHcr4=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.1/go.mod h1:mPgqFBu/woKTVYWyNk8cO3kh4S/f4aRFZrvOnp3hmCs=
|
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
|
||||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
|
|
31
internal/cmd/cmd.go
Normal file
31
internal/cmd/cmd.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner interface {
|
||||||
|
Parse([]string) error
|
||||||
|
Name() string
|
||||||
|
Run() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFunc(name, summary string, flagset *flag.FlagSet) func() {
|
||||||
|
return func() {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&builder, "SUMMARY:\n %s - %s\n\nUSAGE:\n spruce %s [flags]\n\nFLAGS:", name, summary, name)
|
||||||
|
|
||||||
|
flagset.VisitAll(func(f *flag.Flag) {
|
||||||
|
fmt.Fprintf(&builder, "\n -%s, --%s\n %s", f.Name, f.Name, f.Usage)
|
||||||
|
})
|
||||||
|
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
w := flag.CommandLine.Output()
|
||||||
|
|
||||||
|
fmt.Fprint(w, builder.String())
|
||||||
|
}
|
||||||
|
}
|
142
internal/cmd/create.go
Normal file
142
internal/cmd/create.go
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateCommand struct {
|
||||||
|
*flag.FlagSet
|
||||||
|
summary string
|
||||||
|
firstName string
|
||||||
|
lastName string
|
||||||
|
jobTitle string
|
||||||
|
filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateCommand(name, summary string) *CreateCommand {
|
||||||
|
command := CreateCommand{
|
||||||
|
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
|
||||||
|
summary: summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
command.StringVar(&command.filename, "filepath", "cv.json", "specify the output path of the CV JSON file.")
|
||||||
|
command.StringVar(&command.firstName, "first-name", "", "specify your first name.")
|
||||||
|
command.StringVar(&command.jobTitle, "job-title", "", "specify your current job title.")
|
||||||
|
command.StringVar(&command.lastName, "last-name", "", "specify your last name.")
|
||||||
|
|
||||||
|
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
|
||||||
|
|
||||||
|
return &command
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CreateCommand) Run() error {
|
||||||
|
detailLen := 2
|
||||||
|
data := cv.NewCV(c.firstName, c.lastName, c.jobTitle)
|
||||||
|
|
||||||
|
data.Contact = contact()
|
||||||
|
data.Links = links()
|
||||||
|
data.Summary = make([]string, detailLen)
|
||||||
|
data.Skills = skills()
|
||||||
|
data.Employment = employment(detailLen)
|
||||||
|
data.Education = education()
|
||||||
|
data.Interests = make([]string, detailLen)
|
||||||
|
|
||||||
|
file, err := os.Create(c.filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to open %s; %w", c.filename, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
|
||||||
|
if err := encoder.Encode(data); err != nil {
|
||||||
|
return fmt.Errorf("unable to write the data to %s; %w", c.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("CV successfully created", "filename", file.Name())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contact() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"Email": "",
|
||||||
|
"Phone": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func links() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"GitHub": "",
|
||||||
|
"Website": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skills() []cv.Skills {
|
||||||
|
return []cv.Skills{
|
||||||
|
{
|
||||||
|
Category: "",
|
||||||
|
Values: make([]string, 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Category: "",
|
||||||
|
Values: make([]string, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func employment(detailLen int) []cv.Employment {
|
||||||
|
return []cv.Employment{
|
||||||
|
{
|
||||||
|
Company: "",
|
||||||
|
Location: "",
|
||||||
|
LocationType: "",
|
||||||
|
JobTitle: "",
|
||||||
|
Duration: cv.Duration{
|
||||||
|
Start: cv.Date{
|
||||||
|
Day: int64(time.Now().Day()),
|
||||||
|
Month: int64(time.Now().Month()),
|
||||||
|
Year: int64(time.Now().Year()),
|
||||||
|
},
|
||||||
|
End: &cv.Date{
|
||||||
|
Day: int64(time.Now().Day()),
|
||||||
|
Month: int64(time.Now().Month()),
|
||||||
|
Year: int64(time.Now().Year()),
|
||||||
|
},
|
||||||
|
Present: false,
|
||||||
|
},
|
||||||
|
Details: make([]string, detailLen),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func education() []cv.Education {
|
||||||
|
return []cv.Education{
|
||||||
|
{
|
||||||
|
School: "",
|
||||||
|
Location: "",
|
||||||
|
Qualification: "",
|
||||||
|
Duration: cv.Duration{
|
||||||
|
Start: cv.Date{
|
||||||
|
Year: int64(time.Now().Year()),
|
||||||
|
Month: int64(time.Now().Month()),
|
||||||
|
Day: int64(time.Now().Day()),
|
||||||
|
},
|
||||||
|
End: &cv.Date{
|
||||||
|
Year: int64(time.Now().Year()),
|
||||||
|
Month: int64(time.Now().Month()),
|
||||||
|
Day: int64(time.Now().Day()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
98
internal/cmd/generate.go
Normal file
98
internal/cmd/generate.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeflow.dananglin.me.uk/apollo/spruce/internal/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateCommand struct {
|
||||||
|
*flag.FlagSet
|
||||||
|
summary string
|
||||||
|
input string
|
||||||
|
output string
|
||||||
|
employmentHistory int
|
||||||
|
verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type noInputSpecifiedError struct{}
|
||||||
|
|
||||||
|
func (e noInputSpecifiedError) Error() string {
|
||||||
|
return "no input file specified, please set the --input field"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGenerateCommand(name, summary string) *GenerateCommand {
|
||||||
|
command := GenerateCommand{
|
||||||
|
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
|
||||||
|
summary: summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
command.StringVar(&command.input, "input", "", "specify the CV JSON file that you want to input to the builder.")
|
||||||
|
command.StringVar(&command.output, "output", "cv.pdf", "specify the name of the output CV file.")
|
||||||
|
command.IntVar(&command.employmentHistory, "employment-history", 10, "show employment history within these number of years.")
|
||||||
|
command.BoolVar(&command.verbose, "verbose", false, "set to true to enable verbose logging.")
|
||||||
|
|
||||||
|
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
|
||||||
|
|
||||||
|
return &command
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GenerateCommand) Run() error {
|
||||||
|
if c.input == "" {
|
||||||
|
return noInputSpecifiedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyLimit := time.Now().AddDate(-1*c.employmentHistory, 0, 0)
|
||||||
|
|
||||||
|
tempDir, err := os.MkdirTemp("/tmp", "cv-builder-")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create a temporary directory; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := os.RemoveAll(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn(fmt.Sprintf("WARN: An error occurred when removing the temporary directory; %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
pdfFileName, err := pdf.Generate(tempDir, c.input, historyLimit, c.verbose)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create the PDF file; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyfile(filepath.Join(tempDir, "cv.pdf"), c.output); err != nil {
|
||||||
|
return fmt.Errorf("unable to copy %s to %s; %w", pdfFileName, c.output, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyfile(source, destination string) error {
|
||||||
|
inputFile, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to open %s; %w", source, err)
|
||||||
|
}
|
||||||
|
defer inputFile.Close()
|
||||||
|
|
||||||
|
outputFile, err := os.Create(destination)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create %s; %w", destination, err)
|
||||||
|
}
|
||||||
|
defer outputFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(outputFile, inputFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to copy %s to %s; %w", source, destination, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("File successfully copied.", "source", source, "destination", destination)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
56
internal/cmd/version.go
Normal file
56
internal/cmd/version.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionCommand struct {
|
||||||
|
*flag.FlagSet
|
||||||
|
summary string
|
||||||
|
fullVersion bool
|
||||||
|
binaryVersion string
|
||||||
|
buildTime string
|
||||||
|
goVersion string
|
||||||
|
gitCommit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVersionCommand(binaryVersion, buildTime, goVersion, gitCommit, name, summary string) *VersionCommand {
|
||||||
|
command := VersionCommand{
|
||||||
|
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
|
||||||
|
binaryVersion: binaryVersion,
|
||||||
|
buildTime: buildTime,
|
||||||
|
goVersion: goVersion,
|
||||||
|
gitCommit: gitCommit,
|
||||||
|
summary: summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
command.BoolVar(&command.fullVersion, "full", false, "prints the full build information")
|
||||||
|
|
||||||
|
command.Usage = usageFunc(command.Name(), command.summary, command.FlagSet)
|
||||||
|
|
||||||
|
return &command
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *VersionCommand) Run() error {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
if c.fullVersion {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&builder,
|
||||||
|
"Spruce\n Version: %s\n Git commit: %s\n Go version: %s\n Build date: %s\n",
|
||||||
|
c.binaryVersion,
|
||||||
|
c.gitCommit,
|
||||||
|
c.goVersion,
|
||||||
|
c.buildTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(&builder, c.binaryVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(os.Stdout, builder.String())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -1,25 +1,52 @@
|
||||||
package cv
|
package cv
|
||||||
|
|
||||||
import (
|
/*
|
||||||
"os"
|
Code generated by jsonschemagen.
|
||||||
"fmt"
|
DO NOT EDIT.
|
||||||
"encoding/json"
|
*/
|
||||||
)
|
type CV struct {
|
||||||
|
Contact map[string]string `json:"contact,omitempty"`
|
||||||
// NewCV returns a new CV value from the given JSON file.
|
Education []Education `json:"education"`
|
||||||
func NewCV(path string) (CV, error) {
|
Employment []Employment `json:"employment"`
|
||||||
file, err := os.Open(path)
|
FirstName string `json:"firstName"`
|
||||||
if err != nil {
|
Interests []string `json:"interests"`
|
||||||
return CV{}, fmt.Errorf("unable to open %s; %w", path, err)
|
JobTitle string `json:"jobTitle"`
|
||||||
|
LastName string `json:"lastName"`
|
||||||
|
Links map[string]string `json:"links,omitempty"`
|
||||||
|
Skills []Skills `json:"skills"`
|
||||||
|
Summary []string `json:"summary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
decoder := json.NewDecoder(file)
|
type Date struct {
|
||||||
|
Day int64 `json:"day"`
|
||||||
var c CV
|
Month int64 `json:"month"`
|
||||||
|
Year int64 `json:"year"`
|
||||||
if err = decoder.Decode(&c); err != nil {
|
|
||||||
return CV{}, fmt.Errorf("unable to decode JSON data; %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
type Duration struct {
|
||||||
|
End *Date `json:"end,omitempty"`
|
||||||
|
Present bool `json:"present,omitempty"`
|
||||||
|
Start Date `json:"start"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Education struct {
|
||||||
|
Details []string `json:"details,omitempty"`
|
||||||
|
Duration Duration `json:"duration"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Qualification string `json:"qualification"`
|
||||||
|
School string `json:"school"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Employment struct {
|
||||||
|
Company string `json:"company"`
|
||||||
|
Details []string `json:"details,omitempty"`
|
||||||
|
Duration Duration `json:"duration"`
|
||||||
|
JobTitle string `json:"jobTitle"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
LocationType string `json:"locationType,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Skills struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Values []string `json:"values"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
package cv
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CV struct {
|
|
||||||
FirstName string `json:"firstName"`
|
|
||||||
LastName string `json:"lastName"`
|
|
||||||
JobTitle string `json:"jobTitle"`
|
|
||||||
Contact map[string]string `json:"contact"`
|
|
||||||
Links map[string]string `json:"links"`
|
|
||||||
Summary []string `json:"summary"`
|
|
||||||
Skills []Skills `json:"skills"`
|
|
||||||
Employment []Experience `json:"employment"`
|
|
||||||
Education []Experience `json:"education"`
|
|
||||||
Interests []string `json:"interests"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Skills struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
Values []string `json:"values"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Experience struct {
|
|
||||||
Company string `json:"company"`
|
|
||||||
School string `json:"school"`
|
|
||||||
Location string `json:"location"`
|
|
||||||
JobTitle string `json:"jobTitle"`
|
|
||||||
Qualification string `json:"qualification"`
|
|
||||||
Duration Duration `json:"duration"`
|
|
||||||
Details []string `json:"details"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Duration struct {
|
|
||||||
Start Date `json:"start"`
|
|
||||||
End Date `json:"end"`
|
|
||||||
Present bool `json:"present"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Duration) After(t time.Time) (bool, error) {
|
|
||||||
endDate, err := d.End.ParseDate()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return endDate.After(t), nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type Date struct {
|
|
||||||
Year int `json:"year"`
|
|
||||||
Month int `json:"month"`
|
|
||||||
Day int `json:"day"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Date) ParseDate() (time.Time, error) {
|
|
||||||
dateStr := fmt.Sprintf("%d-%02d-%02d", d.Year, d.Month, d.Day)
|
|
||||||
|
|
||||||
date, err := time.Parse(time.DateOnly, dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("unable to parse the date; %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return date, nil
|
|
||||||
}
|
|
62
internal/cv/utils.go
Normal file
62
internal/cv/utils.go
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
package cv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCVFromFile returns a new CV value from the given JSON file.
|
||||||
|
func NewCVFromFile(path string) (CV, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return CV{}, fmt.Errorf("unable to open %s; %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
|
||||||
|
var output CV
|
||||||
|
|
||||||
|
if err = decoder.Decode(&output); err != nil {
|
||||||
|
return CV{}, fmt.Errorf("unable to decode JSON data; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCV returns a new value of type CV.
|
||||||
|
func NewCV(firstName, lastName, jobTitle string) CV {
|
||||||
|
output := CV{
|
||||||
|
FirstName: firstName,
|
||||||
|
LastName: lastName,
|
||||||
|
JobTitle: jobTitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// After returns true if the Duration's end date is set after the earliest experience date.
|
||||||
|
// An error is returned if the end date is not parsed successfully.
|
||||||
|
func (d Duration) After(earliestExperienceDate time.Time) (bool, error) {
|
||||||
|
endDate, err := d.End.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return endDate.After(earliestExperienceDate), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses Date and returns a value of type time.Time.
|
||||||
|
// An error is returned if the parsing fails.
|
||||||
|
func (d Date) Parse() (time.Time, error) {
|
||||||
|
dateStr := fmt.Sprintf("%d-%02d-%02d", d.Year, d.Month, d.Day)
|
||||||
|
|
||||||
|
date, err := time.Parse(time.DateOnly, dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("unable to parse the date; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return date, nil
|
||||||
|
}
|
3
internal/internal.go
Normal file
3
internal/internal.go
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
//go:generate jsonschemagen -s ../schema/cv.schema.json -o cv/cv.go -n cv -r CV -c "Code generated by jsonschemagen." -c "DO NOT EDIT."
|
39
internal/pdf/pdf.go
Normal file
39
internal/pdf/pdf.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate generates the CV PDF document from the JSON file.
|
||||||
|
func Generate(tempDir, input string, historyLimit time.Time, verbose bool) (string, error) {
|
||||||
|
texFileName, err := tex(input, tempDir, historyLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to create the tex file; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Creating the PDF document.")
|
||||||
|
|
||||||
|
pathArg := "--path=" + tempDir
|
||||||
|
|
||||||
|
command := exec.Command("mtxrun", pathArg, "--script", "context", texFileName)
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
command.Stderr = os.Stderr
|
||||||
|
command.Stdout = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("an error occurred when creating the PDF file; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := filepath.Join(tempDir, "cv.pdf")
|
||||||
|
|
||||||
|
slog.Info("PDF document successfully created.", "filename", output)
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
|
@ -30,7 +30,7 @@
|
||||||
\section{EXPERIENCE}
|
\section{EXPERIENCE}
|
||||||
<<- range .Employment>>
|
<<- range .Employment>>
|
||||||
<<- if withinTimePeriod .Duration>>
|
<<- if withinTimePeriod .Duration>>
|
||||||
\jobsection{<<.Company>>}{<<.Location>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
|
\jobsection{<<.Company>>}{<<location .Location .LocationType>>}{<<.JobTitle>>}{<<durationToString .Duration>>}
|
||||||
\startitemize
|
\startitemize
|
||||||
<<range .Details>>
|
<<range .Details>>
|
||||||
\item <<.>>
|
\item <<.>>
|
57
internal/pdf/tex.go
Normal file
57
internal/pdf/tex.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
|
||||||
|
tf "codeflow.dananglin.me.uk/apollo/spruce/internal/templatefuncs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/tex/*
|
||||||
|
var templates embed.FS
|
||||||
|
|
||||||
|
// tex generates the CV document as a Tex file.
|
||||||
|
func tex(input, tempDir string, historyLimit time.Time) (string, error) {
|
||||||
|
slog.Info("Creating the Tex file.")
|
||||||
|
|
||||||
|
data, err := cv.NewCVFromFile(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := filepath.Join(tempDir, "cv.tex")
|
||||||
|
|
||||||
|
file, err := os.Create(output)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to create output file %s; %w", output, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fmap := template.FuncMap{
|
||||||
|
"notLastElement": tf.NotLastElement,
|
||||||
|
"join": tf.JoinSentences,
|
||||||
|
"durationToString": tf.FormatDuration,
|
||||||
|
"withinTimePeriod": tf.WithinTimePeriod(historyLimit),
|
||||||
|
"location": tf.Location,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := template.Must(template.New("cv.tmpl.tex").
|
||||||
|
Funcs(fmap).
|
||||||
|
Delims("<<", ">>").
|
||||||
|
ParseFS(templates, "templates/tex/*.tmpl.tex"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err = tmpl.Execute(file, data); err != nil {
|
||||||
|
return "", fmt.Errorf("unable to execute the CV template. %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Tex file successfully created.", "filename", output)
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package templates
|
package templatefuncs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -8,10 +8,10 @@ import (
|
||||||
|
|
||||||
// FormatDuration outputs the employment/education
|
// FormatDuration outputs the employment/education
|
||||||
// duration as a formatted string.
|
// duration as a formatted string.
|
||||||
func FormatDuration(d cv.Duration) string {
|
func FormatDuration(duration cv.Duration) string {
|
||||||
var start string
|
var start string
|
||||||
|
|
||||||
startDate, err := d.Start.ParseDate()
|
startDate, err := duration.Start.Parse()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
start = "Unknown"
|
start = "Unknown"
|
||||||
|
@ -21,10 +21,10 @@ func FormatDuration(d cv.Duration) string {
|
||||||
|
|
||||||
var end string
|
var end string
|
||||||
|
|
||||||
if d.Present {
|
if duration.Present {
|
||||||
end = "Present"
|
end = "Present"
|
||||||
} else {
|
} else {
|
||||||
endDate, err := d.End.ParseDate()
|
endDate, err := duration.End.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
end = "Unknown"
|
end = "Unknown"
|
||||||
} else {
|
} else {
|
|
@ -1,4 +1,4 @@
|
||||||
package templates
|
package templatefuncs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
11
internal/templatefuncs/location.go
Normal file
11
internal/templatefuncs/location.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package templatefuncs
|
||||||
|
|
||||||
|
// Location returns a string with both the location and the
|
||||||
|
// location type if the location type is not empty.
|
||||||
|
func Location(location, locationType string) string {
|
||||||
|
if locationType != "" {
|
||||||
|
return location + " (" + locationType + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
return location
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package templates
|
package templatefuncs
|
||||||
|
|
||||||
// NotLastElement returns true if an element of an array
|
// NotLastElement returns true if an element of an array
|
||||||
// or a slice is not the last.
|
// or a slice is not the last.
|
|
@ -1,19 +1,20 @@
|
||||||
package templates
|
package templatefuncs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
|
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithinTimePeriod returns true if the employment's end date is within
|
// WithinTimePeriod returns true if the employment's end date is within
|
||||||
// the specified time period.
|
// the specified time period.
|
||||||
func WithinTimePeriod(t time.Time) func(d cv.Duration) bool {
|
func WithinTimePeriod(date time.Time) func(d cv.Duration) bool {
|
||||||
return func(d cv.Duration) bool {
|
return func(d cv.Duration) bool {
|
||||||
if d.Present {
|
if d.Present {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := d.After(t)
|
result, err := d.After(date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
132
magefiles/mage.go
Normal file
132
magefiles/mage.go
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
//go:build mage
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/magefile/mage/mg"
|
||||||
|
"github.com/magefile/mage/sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Default = Build
|
||||||
|
binary = "spruce"
|
||||||
|
defaultInstallPrefix = "/usr/local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test run the go tests.
|
||||||
|
// To enable verbose mode set SPRUCE_TEST_VERBOSE=1.
|
||||||
|
// To enable coverage mode set SPRUCE_TEST_COVER=1.
|
||||||
|
func Test() error {
|
||||||
|
goTest := sh.RunCmd("go", "test")
|
||||||
|
|
||||||
|
args := []string{"./..."}
|
||||||
|
|
||||||
|
if os.Getenv("SPRUCE_TEST_VERBOSE") == "1" {
|
||||||
|
args = append(args, "-v")
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("SPRUCE_TEST_COVER") == "1" {
|
||||||
|
args = append(args, "-cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
return goTest(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lint runs golangci-lint against the code.
|
||||||
|
func Lint() error {
|
||||||
|
return sh.RunV("golangci-lint", "run", "--color", "always")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build builds the binary.
|
||||||
|
func Build() error {
|
||||||
|
flags := ldflags()
|
||||||
|
return sh.Run("go", "build", "-ldflags="+flags, "-a", "-o", binary, "./cmd/spruce")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install installs the binary to the execution path.
|
||||||
|
func Install() error {
|
||||||
|
mg.Deps(Build)
|
||||||
|
|
||||||
|
installPrefix := os.Getenv("SPRUCE_INSTALL_PREFIX")
|
||||||
|
if installPrefix == "" {
|
||||||
|
installPrefix = defaultInstallPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(installPrefix, "bin", binary)
|
||||||
|
|
||||||
|
if err := sh.Copy(dest, binary); err != nil {
|
||||||
|
return fmt.Errorf("unable to install %s; %w", binary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentation generates AsciiDoc documentation.
|
||||||
|
func Documentation() error {
|
||||||
|
documentation, err := sh.Output("go", "run", "./cmd/spruce-docgen")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to produce documentation; %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := "./docs/schema.asciidoc"
|
||||||
|
|
||||||
|
file, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create %s; %w", filename, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fmt.Fprint(file, documentation)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean cleans the workspace
|
||||||
|
func Clean() error {
|
||||||
|
files := []string{binary, "cv.pdf", "cv.json"}
|
||||||
|
|
||||||
|
for i := range files {
|
||||||
|
if err := sh.Rm(files[i]); err != nil {
|
||||||
|
return fmt.Errorf("unable to remove %s; %w", binary, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sh.Run("go", "clean", "./..."); err != nil {
|
||||||
|
return fmt.Errorf("unable to run 'go clean'; %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ldflags returns the build flags.
|
||||||
|
func ldflags() string {
|
||||||
|
ldflagsfmt := "-s -w -X main.binaryVersion=%s -X main.gitCommit=%s -X main.goVersion=%s -X main.buildTime=%s"
|
||||||
|
buildTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
return fmt.Sprintf(ldflagsfmt, version(), gitCommit(), runtime.Version(), buildTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// version returns the latest git tag using git describe.
|
||||||
|
func version() string {
|
||||||
|
version, err := sh.Output("git", "describe", "--tags")
|
||||||
|
if err != nil {
|
||||||
|
version = "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCommit returns the current git commit
|
||||||
|
func gitCommit() string {
|
||||||
|
commit, err := sh.Output("git", "rev-parse", "--short", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
commit = "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
return commit
|
||||||
|
}
|
13
magefiles/main.go
Normal file
13
magefiles/main.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/magefile/mage/mage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(mage.Main())
|
||||||
|
}
|
159
main.go
159
main.go
|
@ -1,159 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"codeflow.dananglin.me.uk/apollo/spruce/internal/cv"
|
|
||||||
tf "codeflow.dananglin.me.uk/apollo/spruce/internal/templateFuncs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed templates/tex/*
|
|
||||||
var templates embed.FS
|
|
||||||
|
|
||||||
var version string
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
input string
|
|
||||||
output string
|
|
||||||
employmentHistory int
|
|
||||||
printVersion bool
|
|
||||||
)
|
|
||||||
|
|
||||||
flag.StringVar(&input, "input", "", "specify the CV JSON file that you want to input to the builder.")
|
|
||||||
flag.StringVar(&output, "output", "", "specify the name of the output CV file.")
|
|
||||||
flag.IntVar(&employmentHistory, "employment-history", 10, "show employment history within these number of years.")
|
|
||||||
flag.BoolVar(&printVersion, "version", false, "print the application version and exit.")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if printVersion {
|
|
||||||
Version()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := run(input, output, employmentHistory); err != nil {
|
|
||||||
log.Fatalf("ERROR: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(input, output string, employmentHistory int) error {
|
|
||||||
historyLimit := time.Now().AddDate(-1*employmentHistory, 0, 0)
|
|
||||||
|
|
||||||
tempDir, err := os.MkdirTemp("/tmp", "cv-builder-")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create a temporary directory; %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := os.RemoveAll(tempDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN: An error occurred when removing the temporary directory; %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
texFile, err := tex(input, tempDir, historyLimit)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create the tex file; %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := pdf(tempDir, texFile, output); err != nil {
|
|
||||||
return fmt.Errorf("unable to create the PDF file; %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tex generates the CV document as a Tex file.
|
|
||||||
func tex(input, tempDir string, historyLimit time.Time) (string, error) {
|
|
||||||
c, err := cv.NewCV(input)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("unable to create a new CV value from %s; %w", input, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := filepath.Join(tempDir, "cv.tex")
|
|
||||||
|
|
||||||
file, err := os.Create(output)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("unable to create output file %s; %w", output, err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
fmap := template.FuncMap{
|
|
||||||
"notLastElement": tf.NotLastElement,
|
|
||||||
"join": tf.JoinSentences,
|
|
||||||
"durationToString": tf.FormatDuration,
|
|
||||||
"withinTimePeriod": tf.WithinTimePeriod(historyLimit),
|
|
||||||
}
|
|
||||||
|
|
||||||
t := template.Must(template.New("cv.tmpl.tex").
|
|
||||||
Funcs(fmap).
|
|
||||||
Delims("<<", ">>").
|
|
||||||
ParseFS(templates, "templates/tex/*.tmpl.tex"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err = t.Execute(file, c); err != nil {
|
|
||||||
return "", fmt.Errorf("unable to execute the CV template. %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("INFO: Tex file %s was successfully created.", output)
|
|
||||||
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pdf generates the CV document as a PDF file from the tex file.
|
|
||||||
func pdf(tempDir, texFile, output string) error {
|
|
||||||
pathArg := "--path=" + tempDir
|
|
||||||
|
|
||||||
command := exec.Command("mtxrun", pathArg, "--script", "context", texFile)
|
|
||||||
|
|
||||||
command.Stderr = os.Stderr
|
|
||||||
command.Stdout = os.Stdout
|
|
||||||
|
|
||||||
if err := command.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if output == "" {
|
|
||||||
output = "./cv.pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := copyfile(filepath.Join(tempDir, "cv.pdf"), output); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyfile(input, output string) error {
|
|
||||||
inputFile, err := os.Open(input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to open %s; %w", input, err)
|
|
||||||
}
|
|
||||||
defer inputFile.Close()
|
|
||||||
|
|
||||||
outputFile, err := os.Create(output)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create %s; %w", output, err)
|
|
||||||
}
|
|
||||||
defer outputFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(outputFile, inputFile)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to copy %s to %s; %w", input, output, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Version() {
|
|
||||||
fmt.Printf("Spruce version %s\n", version)
|
|
||||||
}
|
|
216
schema/cv.schema.json
Normal file
216
schema/cv.schema.json
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://codeflow.dananglin.me.uk/apollo/spruce/raw/branch/main/schema/cv.schema.json",
|
||||||
|
|
||||||
|
"title": "CV",
|
||||||
|
"description": "A short written summary of your skills, achievements and experiences in relation to your role.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"firstName": {
|
||||||
|
"description": "Your first name(s).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastName": {
|
||||||
|
"description": "Your last name.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"jobTitle": {
|
||||||
|
"description": "Your current job title.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Your contact information. You can use any key/value pairs here.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "URLs to your online presence such as GitHub or your website. You can use any key/value pairs here.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"description": "A list of sentences summarising your skills, experiences and what you'd like to achieve in the near future.",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"description": "A categorised list of your skills.",
|
||||||
|
"items": { "$ref": "#/$defs/skills" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"employment": {
|
||||||
|
"description": "A list of your employment history.",
|
||||||
|
"items": { "$ref": "#/$defs/employment" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"education": {
|
||||||
|
"description": "A list of your education experiences.",
|
||||||
|
"items": { "$ref": "#/$defs/education" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"interests": {
|
||||||
|
"description": "A list of sentences summarising your interests and hobbies.",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"jobTitle",
|
||||||
|
"summary",
|
||||||
|
"skills",
|
||||||
|
"employment",
|
||||||
|
"education",
|
||||||
|
"interests"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"$defs": {
|
||||||
|
"skills": {
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"description": "The skills category.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "The skills listed in this category.",
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"category",
|
||||||
|
"values"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"employment": {
|
||||||
|
"properties": {
|
||||||
|
"company": {
|
||||||
|
"description": "The company where your work experience took place.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"description": "The duration of the experience.",
|
||||||
|
"$ref": "#/$defs/duration"
|
||||||
|
},
|
||||||
|
"jobTitle": {
|
||||||
|
"description": "The job title of your experience.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"description": "The location where the experience was based.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"locationType": {
|
||||||
|
"description": "The location type of your work experience (e.g. Remote, Hybrid, On-site).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"description": "Further details of the experience (e.g. achievements, daily responsibilities, etc).",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"company",
|
||||||
|
"duration",
|
||||||
|
"jobTitle",
|
||||||
|
"location"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"education": {
|
||||||
|
"properties": {
|
||||||
|
"qualification": {
|
||||||
|
"description": "The qualifications gained from this educational experience.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"school": {
|
||||||
|
"description": "The school or university where you have studied.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"description": "The location where the experience was based.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"description": "The duration of the experience.",
|
||||||
|
"$ref": "#/$defs/duration"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"description": "Further details of the experience.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"qualification",
|
||||||
|
"school",
|
||||||
|
"location",
|
||||||
|
"duration"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"properties": {
|
||||||
|
"start": {
|
||||||
|
"description": "The start date of the experience.",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"description": "The end date of the experience.",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"present": {
|
||||||
|
"description": "Specifies whether you are currently employed or studying at the specified company or educational institute.",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [ "start" ],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"properties": {
|
||||||
|
"year": {
|
||||||
|
"description": "The year (e.g. 2023).",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"month": {
|
||||||
|
"description": "The numerical value of the month (e.g. 5 for May).",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 12,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"day": {
|
||||||
|
"description": "The day of the month.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 31,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"day"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue