diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 3df69c4..cf97433 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -31,7 +31,10 @@ jobs: run: docker build . -t anemon - name: Run Docker container - run: docker run --rm -v ${{ github.workspace }}/assets/latex/output:/output anemon + run: docker run --rm -v ${{ github.workspace }}/assets/latex/output:/app/assets/latex/output anemon + + - name: LSINFO + run: ls -al && tree - name: Upload compiled PDF uses: actions/upload-artifact@v4 diff --git a/Dockerfile b/Dockerfile index c9bd18b..0cec56e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23 as build +FROM ghcr.io/theo-hafsaoui/go-latex-img:c800f95 WORKDIR /app @@ -7,19 +7,5 @@ RUN go mod download && go mod verify COPY . . RUN make build -RUN ./anemon generate -FROM debian:latest -COPY --from=build /app/assets/latex/output/ /internal_output -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y \ - texlive \ - texlive-latex-extra \ - texlive-fonts-extra \ - texlive-xetex \ - texlive-font-utils \ - fonts-font-awesome \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -CMD mkdir -p /tmp_output && cd /internal_output && for i in *.tex; do pdflatex $i -output-directory=/tmp_output || true; done && ls && pwd && ls /tmp_output && cp /internal_output/*.pdf /output/ +CMD ["./anemon", "-g"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..854f834 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 HAFSAOUI Theo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 3dd0a92..3f763a2 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,23 @@ - run: echo "Not yet complete" +run-docker: + sudo docker run -v $(realpath ./assets/latex/output):/app/assets/latex/output anemon:latest + +clean: + sudo rm ./assets/latex/output/* + build: go build lint: golangci-lint run ./... +fmt: + gofmt -s -w . + tidy: go mod tidy test: - go test ./... + go test -v ./... diff --git a/Readme.md b/Readme.md index cdb929b..038fdf6 100644 --- a/Readme.md +++ b/Readme.md @@ -1,139 +1,46 @@ -This project is a simple tool written in Go that generates customized CVs from a directory of Markdown files. The CVs are generated based on a provided `output.yml` file, which specifies different skill sets or features (e.g., "foo" or "bar"). The generated CVs are output in LaTeX format and can be compiled locally or using GitHub Actions. +![Anemon Project](https://github.com/user-attachments/assets/1399b964-5dfc-4dd5-b9ed-f333a3f768fe) -## Project Overview +--- -Given a directory structure containing Markdown files for different sections of a CV (e.g., education, projects, work experience), this tool will: +### Description -1. Parse the directory structure and extract relevant sections for both English and French CVs. -2. Use the provided `output.yml` configuration to determine which features or skills to prioritize in different versions of the CV. -3. For each type (e.g., "foo", "bar") specified in `output.yml`, search the corresponding sections for matching lines. These lines will be moved higher in the CV and emphasized (e.g., through formatting) to draw attention to them. -4. Generate CVs for each type specified in the `output.yml`. The number of CVs generated will depend on the number of types defined. For instance, if there are two types (e.g., "foo" and "bar"), four CVs will be generated (Foo in English, Foo in French, Bar in English, Bar in French). If there are more types, the number of CVs will increase accordingly. -5. The generated CVs are first output in LaTeX format, which can then be compiled into a final PDF. +![Preview Image](https://github.com/user-attachments/assets/845cbda0-89ee-4603-8374-0414a9c50a4a) -## Example `output.yml` +### Getting Started +#### GitHub (Recommended) +To create your CV: +1. **Fork** this repository. +2. Update the `params.yaml` file with your information. +3. Once updated, the CI/CD pipeline will compile the CVs and publish them as artifacts on GitHub. + - Navigate to the **Actions** tab (next to the Pull Requests tab). + - Select the **Compile LaTeX Document** workflow. + - Inside the workflow, click on the job corresponding to your desired CV and download the `compiled-pdf.zip` file from the artifacts section. -Below is an example of an `output.yml` configuration file: +![Action Tab](https://github.com/user-attachments/assets/f15c7c71-022b-4bf2-b79d-2e5ef5f1e65e) -```yaml -info: - name:aNemoN - first_anem:NoName - phone_number:+33.... - ect.. -foo: - - FooFeature1 - - FooFeature2 - - FooFeature3 +#### Local +You can run Anemon locally using one of the following methods: -bar: - - BarFeature1 - - BarFeature2 - - BarFeature3 -``` +1. **Using Docker** + Build the Docker image and run it using the provided `Makefile`. This setup creates a volume to simplify PDF extraction. -This configuration file indicates that for each "foo" and "bar" type CV, the tool will search the relevant sections for "FooFeature1", "FooFeature2", "FooFeature3" (and similarly for "Bar"). Any line containing these features will be moved higher in the CV and emphasized (e.g., bolded or italicized) in the LaTeX output. The number of CVs generated depends on the number of types (e.g., "foo" and "bar") specified. + ```bash + docker build -t anemon . + make run-docker + ``` -## Example Directory Structure +2. **Running Natively** + Alternatively, build and run Anemon natively using the `Makefile`: -Here’s an example of a canonical directory structure for the CV content: + ```bash + ./anemon -g + ``` -``` -cv/ -├── eng -│ ├── education.md -│ ├── project.md -│ └── work.md -└── fr - ├── education.md - ├── project.md - └── work.md -``` + This approach requires a local installation of LaTeX and its necessary packages. Refer to the `Dockerfile` for the list of required dependencies and install their equivalents for your system. -The structure includes separate directories for English (`eng`) and French (`fr`) versions of the CV. Each directory contains Markdown files for different sections of the CV, such as `education.md`, `project.md`, and `work.md`. +### Personalization +You may want to customize the templates to fit your needs. You’re free to modify or create new templates, but ensure the `%something%` placeholders remain intact, as these serve as anchors for Anemon. -## Workflow - -### Conceptual Flow - -The following sequence diagram outlines the process flow of the tool: - -```mermaid -sequenceDiagram -CV->>+DirectoryReader: Ask to read the output.yml -DirectoryReader-->>-CV: Return the Output -CV->>+DirectoryReader: Ask to read the CV directory tree -DirectoryReader-->>-CV: Return the Sections -CV->>CV: Search for features and prioritize them -CV->>CV: Create the LaTeX CV variants -CV -->>System: Launch the compilation of the CVs -``` - -### Class Design - -The following class diagram illustrates the key components of the system: - -```mermaid -classDiagram -direction RL - -CV -- ProjectSection -CV -- SkillsSection -CV -- EducationSection -CV -- DirectoryReader -CV -- Output -CV -- ProfessionalSection - -class CV{ - Section sections - generateLatexCV() - compileLatexCV() -} - -class Output{ - map[string]string Target -} - -class DirectoryReader{ - Array
getSections() - Output getOutput() -} - -namespace Section { - - class ProjectSection { - String Title - String Description - String Skill - String Link - void SortFor(output) - } - - class ProfessionalSection { - String Title - String Description - String Skill - String Link - void SortFor(output) - } - - class SkillsSection { - Array Skill - void SortFor(output) - } - - class EducationSection { - String Title - String Description - String Link - void SortFor(output) - } -} -``` - -## How to Use - -1. **Clone the Repository**: Clone this repository to your local machine. -2. **Prepare Your Directory**: Structure your directory with Markdown files as shown in the example above. -3. **Create Your `output.yml`**: Define your CV variations and the features to prioritize in an `output.yml` file. -4. **Run the Tool**: Execute the Golang program to generate LaTeX files, where specified features are prioritized and emphasized. -5. **Compile the LaTeX Files**: Compile the generated LaTeX files to produce your final CV PDFs. This can be done locally or using GitHub Actions. +### Contribute +Anemon has room for improvement and expansion. Contributions to enhance functionality or fix issues are welcome. Many parts of the project are currently implemented with workaround solutions that could be improved. +Feel free to propose changes that add value to the project! diff --git a/assets/latex/template/template.tex b/assets/latex/template/template.tex index 14b0dde..55023bc 100644 --- a/assets/latex/template/template.tex +++ b/assets/latex/template/template.tex @@ -1,12 +1,7 @@ -%------------------------- -% Resume in Latex % Author : Jake Gutierrez % Based off of: https://github.com/sb2nov/resume % License : MIT -%------------------------ - \documentclass[letterpaper,11pt]{article} - \usepackage{latexsym} \usepackage[empty]{fullpage} \usepackage{titlesec} @@ -21,34 +16,13 @@ \usepackage{xcolor} \usepackage[english]{babel} \usepackage{tabularx} -\definecolor{Nblack}{HTML}{3b4252} - \definecolor{NBLACK}{HTML}{2e3440} - \definecolor{Nwhite}{HTML}{eceff4} - \definecolor{nwhite}{HTML}{e5e9f0} - \definecolor{Nblue}{HTML}{5e81ac} - \definecolor{Nurl}{HTML}{8fbcbb} - \definecolor{nblue}{HTML}{005A92} +\definecolor{nblue}{HTML}{005A92} \input{glyphtounicode} - - -%----------FONT OPTIONS---------- -% sans-serif -% \usepackage[sfdefault]{FiraSans} -% \usepackage[sfdefault]{roboto} -% \usepackage[sfdefault]{noto-sans} -% \usepackage[default]{sourcesanspro} - -% serif -% \usepackage{CormorantGaramond} -% \usepackage{charter} - - \pagestyle{fancy} -\fancyhf{} % clear all header and footer fields +\fancyhf{} \fancyfoot{} \renewcommand{\headrulewidth}{0pt} \renewcommand{\footrulewidth}{0pt} - % Adjust margins \addtolength{\oddsidemargin}{-0.5in} \addtolength{\evensidemargin}{-0.5in} @@ -106,9 +80,7 @@ } \newcommand{\resumeSubItem}[1]{\resumeItem{#1}\vspace{-4pt}} - \renewcommand\labelitemii{$\vcenter{\hbox{\tiny$\bullet$}}$} - \newcommand{\resumeSubHeadingListStart}{\begin{itemize}[leftmargin=0.15in, label={}]} \newcommand{\resumeSubHeadingListEnd}{\end{itemize}} \newcommand{\resumeItemListStart}{\begin{itemize}} @@ -120,43 +92,38 @@ \begin{document} -\begin{center} - \textbf{\Huge \scshape \textcolor{nblue}{Anemon Vincent}} \\ \vspace{1pt} - \small +33 6 26 26 50 07 $|$ \href{mailto:anemon@pm.me}{\underline{anemon@pm.me}} \\ - \href{https://linkedin.com/in/anemon/}{\underline{linkedin.com/in/anemon}} $|$ - \href{https://github.com/anemon}{\underline{github.com/anemon}} -\end{center} + %VARS% + \begin{center} + \textbf{\Huge \scshape \textcolor{nblue}{\Name \ \FirstName}} \\ \vspace{1pt} + \small \Number \ $|$ \href{mailto:\Mail}{\underline{\Mail}} \\ + \href{\LinkedIn}{\underline{linkedin.com/in/\Name}} $|$ + \href{\GitHub}{\underline{github.com/\Name}} + \end{center} + + %-----------EDUCATION----------- + \section{EDUCATION} + \resumeSubHeadingListStart + %EDUCATION_SECTIONS% + \resumeSubHeadingListEnd + + %-----------EXPERIENCE----------- + \section{EXPERIENCE} + \resumeSubHeadingListStart + %EXPERIENCE_SECTIONS% + \resumeSubHeadingListEnd + + %-----------PROJECTS----------- + \section{Projects} + \resumeSubHeadingListStart + %PROJECTS_SECTIONS% + \resumeSubHeadingListEnd + + %-----------PROGRAMMING SKILLS----------- + \section{Skills} + \begin{itemize}[leftmargin=0.15in, label={}] + %SKILL_SECTIONS% + \end{itemize} + + %------------------------------------------- - -%-----------EDUCATION----------- -\section{EDUCATION} -\resumeSubHeadingListStart -%EDUCATION_SECTIONS% -\resumeSubHeadingListEnd - - -%-----------EXPERIENCE----------- -\section{EXPERIENCE} -\resumeSubHeadingListStart -%EXPERIENCE_SECTIONS% -\resumeSubHeadingListEnd - - - -%-----------PROJECTS----------- -\section{Projects} -\resumeSubHeadingListStart -%PROJECTS_SECTIONS% -\resumeSubHeadingListEnd - - - -% -%-----------PROGRAMMING SKILLS----------- -\section{Technical Skills} -\begin{itemize}[leftmargin=0.15in, label={}] - %SKILL_SECTIONS% -\end{itemize} - -%------------------------------------------- \end{document} diff --git a/cmd/generate.go b/cmd/generate.go deleted file mode 100644 index d444d3c..0000000 --- a/cmd/generate.go +++ /dev/null @@ -1,78 +0,0 @@ -package cmd - -import ( - m_lang "anemon/internal/markup_languages" - "anemon/internal/walker" - "errors" - "os" - "strings" - - "github.com/spf13/cobra" -) - -var generateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate a CV", - Long: `Generate a CV using the Markdown CV directory in the current work directory`, - RunE: func(cmd *cobra.Command, args []string) error{ - dir, err := os.Getwd() - if err != nil{ - return err - } - CV,err := getSectionMapFrom(dir) - if err != nil { - return err - } - err = createLatexCVFrom(dir,CV) - if err != nil { - return err - } - return nil - }, -} - -//Use a CV map created by `getSectionMapFrom` and write for each lang key a latex CV using the given information -func createLatexCVFrom(dir string, CV map[string]map[string]string )(error){ - for lang := range CV{ - err := m_lang.Init_output(lang+"-CV",dir) - if err != nil{ - return err - } - for sec_name := range CV[lang]{ - for _,paragraphe := range strings.Split(CV[lang][sec_name], "\n\n"){ - if len(paragraphe)<=1 { - continue - } - sec, err := m_lang.Parse(paragraphe) - if err != nil { - return err - } - _,err = m_lang.ApplyToSection(sec,sec_name,dir+"/assets/latex/output/"+lang+"-CV.tex") - if err != nil { - return err - } - } - } - } - return nil -} - -//TODO consider struct for this map of map -//Walk throught the CV directory and return a map of lang within each their is a map of section -func getSectionMapFrom(dir string)(map[string]map[string]string,error){ - cv_path := dir+"/cv" - _, err := os.Stat(cv_path) - if err != nil{ - if os.IsNotExist(err) { return nil, errors.New("No `cv` directory found at:"+cv_path) } - return nil,err - } - result, err := walker.WalkCV(cv_path) - if err != nil{ - return nil,err - } - return result,nil -} - -func init() { - rootCmd.AddCommand(generateCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 8ae1e7e..57dcf44 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,19 +1,50 @@ package cmd import ( - "os" - "github.com/spf13/cobra" + "anemon/internal/adapters/input" + "github.com/spf13/cobra" + "os" ) var rootCmd = &cobra.Command{ - Use: "anemon", - Short: "a CV genrator", - Long: `This CLI tool, written in Go, automates the generation of customized CVs from Markdown files based on a specified configuration. It parses CV sections in - multiple languages, prioritizes key skills or features as defined in an output.yml file, and outputs LaTeX files for each CV version, ready for compilation.`, + Use: "anemon", + Short: "A CV generator", + Long: `This CLI tool, automates the generation of customized CVs from Markdown files based on a specified configuration.`, + RunE: func(cmd *cobra.Command, args []string) error { + threshold, err := cmd.Flags().GetInt("threshold") + if err != nil { return err } + + root, err := os.Getwd() + if err != nil { return err } + + if err != nil { + return err + } + input.ChangeOverflowThreshold(threshold) + + generate, err := cmd.Flags().GetBool("generate") + if err != nil { return err } + + if generate { + return input.GenerateCVFromMarkDownToLatex(root) + } + + info, err := cmd.Flags().GetBool("cvInfo") + if err != nil { return err } + if info{ + input.PrintAllCvs(root) + return nil + } + + return nil + }, } func Execute() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } + rootCmd.Flags().IntP("threshold", "t", 1, "Set the page overflow threshold (default 1)") + rootCmd.Flags().BoolP("generate", "g", false, "Generate a CV") + rootCmd.Flags().BoolP("cvInfo", "i", false, "Get all the info of all the cvs") + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cv/eng/education.md b/cv/eng/education.md index 425a066..105bf9e 100644 --- a/cv/eng/education.md +++ b/cv/eng/education.md @@ -1,9 +1,9 @@ # Master's in Computer Science, Software Engineering -## [Link to Master's Program](https://www.univ-smith.edu/masters-software-engineering) +## https://www.univ-smith.edu/masters-software-engineering ### University of Smith #### 2024 # Bachelor's in Computer Science -## [Link to Bachelor's Program](https://www.univ-smith.edu/bachelors-computer-science) +## https://www.univ-smith.edu/bachelors-computer-science ### University of Smith #### 2024 diff --git a/cv/eng/professional.md b/cv/eng/professional.md index f8228f4..46c2d9e 100644 --- a/cv/eng/professional.md +++ b/cv/eng/professional.md @@ -15,4 +15,3 @@ - Implemented an API to streamline data management processes, improving flexibility and scalability. - Set up a deployment pipeline, automating and improving the efficiency of software updates. - Optimized system performance, significantly reducing response times and improving user experience. - diff --git a/cv/eng/project.md b/cv/eng/project.md index f0a9b48..e409d00 100644 --- a/cv/eng/project.md +++ b/cv/eng/project.md @@ -1,15 +1,13 @@ -## Project A +# Project A ## Language A, Framework A, Database A, Version Control A -### [Link to Repository](https://github.com/ExampleUser/projectA) +### https://github.com/ExampleUser/projectA - Developed using Language A and Framework A, with Database A for data management. - Executed in an agile environment, focusing on collaborative practices such as Version Control A Flow, Pull Requests, Kanban, and Continuous Integration. # Project B ## Language B, ORM B, Containerization Tool B, API Security B -### [Link to Repository](https://github.com/ExampleUser/projectB) +### https://github.com/ExampleUser/projectB - Designed and developed a specialized tool using Language B, Containerization Tool B, API Communication B, and API Security B. - Separated front-end and back-end using Containerization Tool B, with orchestration handled by Container Orchestration B. - Ensured secure communication between components via a RESTful API with API Security B tokens. - Collaborated within a team using agile methodologies, specifically SCRUM, and applied DevOps practices for streamlined development. - - diff --git a/go.mod b/go.mod index 7510094..0c4bfec 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module anemon go 1.22.0 -require github.com/spf13/cobra v1.8.1 +require ( + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 912390a..a01295b 100644 --- a/go.sum +++ b/go.sum @@ -6,5 +6,7 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapters/input/cli.go b/internal/adapters/input/cli.go new file mode 100644 index 0000000..11475e3 --- /dev/null +++ b/internal/adapters/input/cli.go @@ -0,0 +1,30 @@ +package input + +import ( + "anemon/internal/adapters/output" + "anemon/internal/core" +) + +// Use the implementation for markdown and latex to generate latex CV from a tree dir of mardown document +func GenerateCVFromMarkDownToLatex(root string) error { + var builder core.BuilderService = core.BuilderService{} + builder.SetRoot(root) + builder.SetSource(&MarkdownSource{}) + builder.SetParamsSource(&YamlSource{}) + builder.SetTemplateReader(&output.LatexReader{}) + builder.SetTemplateProcessor(&output.LatexProccesor{}) + builder.SetCompiler(&output.LatexCompiler{}) + service := builder.GetService() + return service.GenerateTemplates() +} + +// Change the threshold for the regeration of the PDF +func ChangeOverflowThreshold(newThreshold int) { + core.SetOverflowThreshold(newThreshold) +} +// Print info in the prompt for all the cvs +func PrintAllCvs(root string) { + source := MarkdownSource{} + core.GetInfoAllCvs(root,&source) +} + diff --git a/internal/adapters/input/input_test.go b/internal/adapters/input/input_test.go new file mode 100644 index 0000000..399ed35 --- /dev/null +++ b/internal/adapters/input/input_test.go @@ -0,0 +1,212 @@ +package input + +import ( + core "anemon/internal/core" + "os" + "path/filepath" + "reflect" + "testing" +) + +var ( + yamlContent = ` +info: + name: Doe + firstname: John + number: "12345" + mail: john.doe@example.com + github: johndoe + linkedin: john-doe-linkedin +variante: + optionA: + - "value1" + - "value2" + optionB: + - "value3" +` + work_input = ` +# Back-End Intern +## February 2024 -- August 2024 +### TechCorp +#### Internship +- Assisted in developing and optimizing a key business process for expanding into new markets, collaborating with various teams to ensure compliance and smooth integration. +- Participated in the migration of core backend logic from a monolithic application to a microservice architecture, improving system performance and scalability. +- Enhanced system monitoring and reliability by implementing tracing mechanisms and performance objectives. + +# Back-End Intern +## February 2024 -- August 2024 +### TechCorp +#### Internship +- Assisted in developing and optimizing a key business process for expanding into new markets, collaborating with various teams to ensure compliance and smooth integration. +- Participated in the migration of core backend logic from a monolithic application to a microservice architecture, improving system performance and scalability. +- Enhanced system monitoring and reliability by implementing tracing mechanisms and performance objectives.` + + skill_input = ` +**Langage** + - Langage A, Langage B, Langage C, Langage D, Langage E, Langage F, Langage G/H + +**Langage** + - Langage A, Langage B, Langage C, Langage D, Langage E, Langage F, Langage G/H` + invalid_skill_input = ` + - Langage A, Langage B, Langage C, Langage D, Langage E, Langage F, Langage G/H + +**Langag + - Langage A, Langage B, Langage C, Langage D, Langage E, Langage F, Langage G/H` + + skill_paragraphe = core.Paragraphe{H1: "Langage", H2: "Langage A, Langage B, Langage C, Langage D, Langage E, Langage F, Langage G/H"} + + work_paragraphe = core.Paragraphe{H1: "Back-End Intern", H2: "February 2024 -- August 2024", + H3: "TechCorp", H4: "Internship", Items: []string{ + "Assisted in developing and optimizing a key business process for expanding into new markets, collaborating with various teams to ensure compliance and smooth integration.", + "Participated in the migration of core backend logic from a monolithic application to a microservice architecture, improving system performance and scalability.", + "Enhanced system monitoring and reliability by implementing tracing mechanisms and performance objectives."}} + + invalid_input = "ajsdlhsaeld##dafdbhkbhkjsd##" + work_expected_result = []core.Paragraphe{work_paragraphe, work_paragraphe} + skill_expected_result = []core.Paragraphe{skill_paragraphe, skill_paragraphe} + + paths = []struct { + relativePath string + content string + }{ + {"cv/eng/education.md", work_input}, + {"cv/eng/project.md", work_input}, + {"cv/eng/work.md", work_input}, + {"cv/eng/skill.md", skill_input}, + {"cv/fr/education.md", work_input}, + {"cv/fr/work.md", work_input}, + {"cv/fr/skill.md", skill_input}, + } +) + +func TestParagraphe(t *testing.T) { + + t.Run("Work Paragraphes should return a slice of valid Paragraphe", func(t *testing.T) { + got := getParagrapheFrom(work_input) + want := work_expected_result + if !reflect.DeepEqual(got[0], want[0]) { + t.Fatalf("the first Paragraphe should be :\n%s\n got :%s", want, got) + } + + if !reflect.DeepEqual(got[1], want[1]) { + t.Fatalf("the first Paragraphe should be :\n%s\n got :%s", want, got) + } + }) + + t.Run("Invalid input should return nothing", func(t *testing.T) { + result := getParagrapheFrom(invalid_input) + if result != nil { + t.Fatalf("Invalid input should return nil got %v", result) + } + }) + + t.Run("Skill Paragraphe should be return from valid input", func(t *testing.T) { + got := getParagrapheFrom(skill_input) + want := skill_expected_result + if !reflect.DeepEqual(got, want) { + t.Fatalf("the first Paragraphe should be :\n%s\n got :%s", want, got) + } + }) + + t.Run("Invalid skill Paragraphe should return nil", func(t *testing.T) { + got := getParagrapheFrom(invalid_skill_input) + if got != nil { + t.Fatalf("Invalid input should return nil got %v", got) + } + }) +} + +func TestSections(t *testing.T) { + rootDir := t.TempDir() + for _, p := range paths { + fullPath := filepath.Join(rootDir, p.relativePath) + if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil { + t.Fatalf("Failed to create directories: %v", err) + } + if err := os.WriteFile(fullPath, []byte(p.content), os.ModePerm); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + } + + t.Run("Should return a valid list of cv", func(t *testing.T) { + source := MarkdownSource{} + got, err := source.GetCVsFrom(rootDir) + if err != nil { + t.Fatalf("Failed to getCV got %s", err) + } + + lang_got := got[len(got)-1].Lang + l_want := "fr" + if lang_got != l_want { + t.Fatalf("Should have %s got %s", l_want, lang_got) + } + + sec_got := len(got[len(got)-1].Sections) + s_want := 3 + if sec_got != s_want { + t.Fatalf("Should have %d got %d", s_want, sec_got) + } + + t_sec_got := got[len(got)-1].Sections[len(got[len(got)-1].Sections)-1].Title + t_s_want := "work" + if t_sec_got != t_s_want { + t.Fatalf("Should have %s got %s", t_s_want, t_sec_got) + } + + p_t_sec_got := got[len(got)-1].Sections[len(got[len(got)-1].Sections)-1].Paragraphes + p_t_s_want := work_expected_result + + if len(p_t_sec_got) != len(p_t_s_want) { + t.Fatalf("Should have len %d got %d", len(p_t_s_want), len(p_t_sec_got)) + } + + if p_t_sec_got[len(p_t_sec_got)-1].H1 != p_t_s_want[len(p_t_s_want)-1].H1 { + t.Fatalf("Should have title %s got %s", p_t_s_want[len(p_t_s_want)-1].H1, p_t_sec_got[len(p_t_sec_got)-1].H1) + } + + }) +} + +func TestGetParamsFrom(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + yamlFilePath := filepath.Join(tempDir, "params.yml") + err = os.WriteFile(yamlFilePath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write YAML file: %v", err) + } + + source := &YamlSource{} + params, err := source.GetParamsFrom(tempDir) + if err != nil { + t.Fatalf("GetParamsFrom returned an error: %v", err) + } + expectedParams := core.Params{ + Info: struct { + Name string `yaml:"name"` + FirstName string `yaml:"firstname"` + Number string `yaml:"number"` + Mail string `yaml:"mail"` + GitHub string `yaml:"github"` + LinkedIn string `yaml:"linkedin"` + }{ + Name: "Doe", + FirstName: "John", + Number: "12345", + Mail: "john.doe@example.com", + GitHub: "johndoe", + LinkedIn: "john-doe-linkedin", + }, + Variante: map[string][]string{ + "optionA": {"value1", "value2"}, + "optionB": {"value3"}, + }, + } + if !reflect.DeepEqual(params, expectedParams) { + t.Errorf("Expected %+v, but got %+v", expectedParams, params) + } +} diff --git a/internal/adapters/input/markdown_tree_reader.go b/internal/adapters/input/markdown_tree_reader.go new file mode 100644 index 0000000..66c5d61 --- /dev/null +++ b/internal/adapters/input/markdown_tree_reader.go @@ -0,0 +1,167 @@ +package input + +import ( + core "anemon/internal/core" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" +) + +type MarkdownSource struct{} + +var hashtagRegex = regexp.MustCompile(`^#+`) + +// `GetCVFrom` takes a root directory and extracts Markdown documents to generate a list of CV type. +// +// This function assumes the directory structure is a tree with a depth of 3, where each leaf +// is a Markdown (.md) document. Each document may contain multiple paragraphs, but the headers +// should not repeat within the same document. +func (*MarkdownSource) GetCVsFrom(root string) ([]core.CV, error) { + cvsPath := root + "/cv" + cvs := make([]core.CV, 0) + + current_lang := "" + current_sections := make([]core.Section, 0) + has_been_inside_dir := false + + err := filepath.Walk(cvsPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if has_been_inside_dir { + cvs[len(cvs)-1].Sections = current_sections + current_sections = make([]core.Section, 0) + has_been_inside_dir = false + } + current_lang = info.Name() + if current_lang != "cv"{ + cvs = append(cvs, core.CV{Lang: current_lang}) + } + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".md") { + has_been_inside_dir = true + if current_lang == "" { + return errors.New("markdown file found before lang directory") + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + new_section := core.Section{Title: strings.TrimRight(info.Name(), ".md"), + Paragraphes: getParagrapheFrom(string(content))} + current_sections = append(current_sections, new_section) + } + return nil + }) + + if err != nil { + return nil, err + } + cvs[len(cvs)-1].Sections = current_sections + + return cvs, nil +} + +// Take string in the md format and return a slice of core.Paragraphe for each paragraph inside of it +// We define a paragraph by text block where \n\n indicate a separation +func getParagrapheFrom(s_section string) []core.Paragraphe { + paragraphs := make([]core.Paragraphe, 0) + for _, paragraphe := range strings.Split(s_section, "\n\n") { + n_paragraphe, err := parse_paragraphe(paragraphe) + if is_empty(n_paragraphe) { + continue + } + if err != nil { + fmt.Println("Failed to parse paragraphe ") + } else { + paragraphs = append(paragraphs, n_paragraphe) + } + } + if len(paragraphs) == 0 { + return nil + } + return paragraphs +} + +// Return if a paragraphs has no header and no items +func is_empty(p core.Paragraphe) bool { + no_header := p.H1 == "" && p.H2 == "" && p.H3 == "" && p.H4 == "" + no_items := len(p.Items) == 0 + return no_header && no_items +} + +/* +Parse parses a Markdown-like `paragraph` into a `Paragraph`, +extracting headings and descriptions based on the number of leading hashtags or stars. +Returns an error if the format is invalid. +*/ +func parse_paragraphe(paragraph string) (core.Paragraphe, error) { + var ( + n_paragraphe core.Paragraphe + bulletPrefix = "- " + skillAsteriskCount = 4 // Number of asterisks that signify a skill block + ) + if len(strings.Split(paragraph, "\n\n")) > 1 { + return n_paragraphe, errors.New("Tried to parse multiple paragraphs into a single section") + } + wasASkill := false + lines := strings.Split(strings.TrimRight(paragraph, "\n"), "\n") + for _, line := range lines { + if len(line) == 0 { + continue + } + nbHashtags := len(hashtagRegex.FindString(line)) + if wasASkill { + wasASkill = false + n_paragraphe.H2 = strings.TrimLeft(line, bulletPrefix) + continue + } + if nbHashtags == 0 && strings.HasPrefix(line, "*") && len(strings.Trim(line, "*")) == len(line)-skillAsteriskCount { + n_paragraphe.H1 = strings.Trim(line, "*") + wasASkill = true + continue + } + if err := handleLine(&n_paragraphe, line, nbHashtags); err != nil { + return n_paragraphe, err + } + } + return n_paragraphe, nil +} + +// handleLine processes a line based on the number of leading hashtags +func handleLine(n_paragraphe *core.Paragraphe, line string, nbHashtags int) error { + if nbHashtags > 0 && line[nbHashtags] != ' ' { + return fmt.Errorf("Err: cannot parse this md line {%s}, '#' should be followed by space", line) + } + switch nbHashtags { + case 1: + processesHeader(&n_paragraphe.H1, line, nbHashtags) + case 2: + processesHeader(&n_paragraphe.H2, line, nbHashtags) + case 3: + processesHeader(&n_paragraphe.H3, line, nbHashtags) + case 4: + processesHeader(&n_paragraphe.H4, line, nbHashtags) + case 0: + if strings.HasPrefix(line, "- ") && len(line) > 1 { + n_paragraphe.Items = append(n_paragraphe.Items, strings.TrimLeft(line, "- ")) + } + default: + return fmt.Errorf("cannot parse this md line {%s}", line) + } + return nil +} + +// Affect to the Header the line without the `-` +func processesHeader(pt_header *string, line string, nbHashtags int) { + if *pt_header != "" { + slog.Warn("Trying to overload Header", "oldHeader", + *pt_header, "newHeader", line[nbHashtags+1:]) + } + *pt_header = line[nbHashtags+1:] +} diff --git a/internal/adapters/input/yaml.go b/internal/adapters/input/yaml.go new file mode 100644 index 0000000..2564191 --- /dev/null +++ b/internal/adapters/input/yaml.go @@ -0,0 +1,22 @@ +package input + +import ( + core "anemon/internal/core" + "gopkg.in/yaml.v3" + "os" +) + +type YamlSource struct{} + +func (*YamlSource) GetParamsFrom(root string) (core.Params, error) { + params := core.Params{} + yamlFile, err := os.ReadFile(root + "/params.yml") + if err != nil { + return params, err + } + err = yaml.Unmarshal(yamlFile, ¶ms) + if err != nil { + return params, err + } + return params, nil +} diff --git a/internal/adapters/output/compiler.go b/internal/adapters/output/compiler.go new file mode 100644 index 0000000..8e74924 --- /dev/null +++ b/internal/adapters/output/compiler.go @@ -0,0 +1,79 @@ +package output + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" +) + +const COMPILER = "pdflatex" + +var REGEX = regexp.MustCompile(`(\d+) page`) + +type LatexCompiler struct{} + +// Compile the templates into PDFs and return the number of page of the longest one +func (*LatexCompiler) CompileTemplate(root string) (int, error) { + templates, err := getListOfTemplate(root) + if err != nil { + return 0, err + } + max_nb_of_page := 0 + page_nb := make(chan int, len(templates)) + var wg sync.WaitGroup + for _, template := range templates { + wg.Add(1) + go compile(template, root, &wg, page_nb) + pdf_pages_nb := <-page_nb + slog.Info("Number of pages", "pages", pdf_pages_nb) + max_nb_of_page = max(pdf_pages_nb, max_nb_of_page) + } + close(page_nb) + wg.Wait() + return max_nb_of_page, nil +} + +// Compile the template into a pdf +func compile(template string, root string, wg *sync.WaitGroup, c_page_nb chan int) { + defer wg.Done() + cmd := exec.Command(COMPILER, "-interaction=nonstopmode", + "-output-directory="+root+"/assets/latex/output", template) + log, err := cmd.Output() + if err != nil { + slog.Warn("error(s) to compile file:" + template) + } + page_nb := -1 + log_page := REGEX.FindStringSubmatch(string(log)) + if len(log_page) < 1 { + slog.Error("failed to compile file didnt get the number of pages:" + template) + fmt.Println(log_page) + } else { + page_nb, err = strconv.Atoi(log_page[1]) + if err != nil { + slog.Error(err.Error()) + } + } + c_page_nb <- page_nb +} + +// Return the path of latex file inside the template directory +func getListOfTemplate(root string) ([]string, error) { + var res []string + templates, err := os.ReadDir(root + "/assets/latex/output") + if err != nil { + slog.Error("failed to read directory because: " + err.Error()) + return res, err + } + for _, template := range templates { + if strings.HasSuffix(template.Name(), ".tex") { + res = append(res, root+"/assets/latex/output/"+template.Name()) + } + } + fmt.Println(res) + return res, nil +} diff --git a/internal/adapters/output/latex_test.go b/internal/adapters/output/latex_test.go new file mode 100644 index 0000000..173c4a9 --- /dev/null +++ b/internal/adapters/output/latex_test.go @@ -0,0 +1,341 @@ +package output + +import ( + "anemon/internal/core" + "os" + "path/filepath" + "strings" + "testing" +) + +func removeWhitespace(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, " ", ""), "\n", "") +} + +func TestApplySectionToTemplate(t *testing.T) { + + t.Run("happy path - should apply Professional section template with headers and items", func(t *testing.T) { + template := "Start\n%EXPERIENCE_SECTIONS%\nEnd" + headers := []string{"Company Name", "Position", "https://company.com", "Company"} + items := []string{"Task 1", "Task 2"} + keyword := []string{"1", "2"} + + want := strings.TrimSpace(`Start +%EXPERIENCE_SECTIONS% +\resumeSubheading + {Company Name}{Position} + {\href{https://company.com}{Company}}{ } +\resumeItemListStart + \resumeItem{Task \textbf{1}} +\resumeItem{Task \textbf{2}} +\resumeItemListEnd +End`) + + processor := LatexProccesor{} + got, err := processor.ApplySectionToTemplate(template, headers, items, "Professional",keyword) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("ApplySectionToTemplate happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("happy path - should apply Project section template with headers and items", func(t *testing.T) { + template := "Start\n%PROJECTS_SECTIONS%\nEnd" + headers := []string{"Project Name", "Project Description", "https://github.com/project"} + items := []string{"Feature 1", "Feature 2"} + keyword := []string{"1", "2"} + + want := `Start +%PROJECTS_SECTIONS% +\resumeProjectHeading +{\textbf{Project Name} | \emph{Project Description \href{https://github.com/project}{\faIcon{github}}}}{} +\resumeItemListStart + \resumeItem{Feature \textbf{1}} +\resumeItem{Feature \textbf{2}} + +\resumeItemListEnd + +End` + + + processor := LatexProccesor{} + got, err := processor.ApplySectionToTemplate(template, headers, items, "Project",keyword) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("ApplySectionToTemplate happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("failure - should return error for unsupported section", func(t *testing.T) { + template := "Start\n%EXPERIENCE_SECTIONS%\nEnd" + headers := []string{"Company Name", "Position", "https://company.com", "Company"} + items := []string{"Task 1", "Task 2"} + keyword := []string{"1", "2"} + + processor := LatexProccesor{} + _, err := processor.ApplySectionToTemplate(template, headers, items, "UnsupportedSection",keyword) + if err == nil || err.Error() != "Don't know type UnsupportedSection" { + t.Errorf("expected error 'Don't know type UnsupportedSection', got %v", err) + } + }) + + t.Run("missing headers - should handle missing headers gracefully in Project section", func(t *testing.T) { + template := "Start\n%PROJECTS_SECTIONS%\nEnd" + headers := []string{"Project Name"} + items := []string{"Feature 1", "Feature 2"} + + want := strings.TrimSpace(`Start +%PROJECTS_SECTIONS% +\resumeProjectHeading +{\textbf{Project Name} | \emph{$2 \href{$3}{\faIcon{github}}}}{} +\resumeItemListStart + \resumeItem{Feature 1} + \resumeItem{Feature 2} +\resumeItemListEnd +End`) + + processor := LatexProccesor{} + got, err := processor.ApplySectionToTemplate(template, headers, items, "Project",nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("ApplySectionToTemplate missing headers case failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) +} + +func TestReplaceWithSectionTemplate(t *testing.T) { + + t.Run("happy path - should replace hook with section template including headers and items", func(t *testing.T) { + template := "Start\n%PROJECTS_SECTIONS%\nEnd" + headers := []string{"Project Name", "Project Description", "https://github.com/project"} + items := []string{"Feature 1", "Feature 2"} + want := strings.TrimSpace(`Start +%PROJECTS_SECTIONS% +\resumeProjectHeading +{\textbf{Project Name} | \emph{Project Description \href{https://github.com/project}{\faIcon {github}}}}{} +\resumeItemListStart + \resumeItem{Feature 1} +\resumeItem{Feature 2} + +\resumeItemListEnd + +End`) + + got := strings.TrimSpace(replaceWithSectionTemplate(template, ProjectTemplate, headers, items,nil)) + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("replaceWithSectionTemplate happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("failure - should work even with missing headers", func(t *testing.T) { + template := "Start\n%PROJECTS_SECTIONS%\nEnd" + headers := []string{"Project Name", "Project Description"} + items := []string{"Feature 1", "Feature 2"} + want := strings.TrimSpace(`Start +%PROJECTS_SECTIONS% +\resumeProjectHeading +{\textbf{Project Name} | \emph{Project Description \href{$3}{\faIcon {github}}}}{} +\resumeItemListStart + \resumeItem{Feature 1} +\resumeItem{Feature 2} + +\resumeItemListEnd + +End`) + + got := strings.TrimSpace(replaceWithSectionTemplate(template, ProjectTemplate, headers, items,nil)) + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("replaceWithSectionTemplate happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) +} + +func TestReplaceHeaders(t *testing.T) { + + t.Run("happy path - should replace %ITEMS% with formatted items", func(t *testing.T) { + template := "This is $1 and this is $2." + section_items := []string{"ok", "ok"} + want := "This is ok and this is ok." + + got := replace_headers(template, section_items) + if got != want { + t.Errorf("replace_headers happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("happy path - should replace %ITEMS% with formatted items even with to much", func(t *testing.T) { + template := "This is $1 and this is $2." + section_items := []string{"ok", "ok", "ko"} + want := "This is ok and this is ok." + + got := replace_headers(template, section_items) + if got != want { + t.Errorf("replace_headers happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("failure - should handle malformed template with extra %ITEMS% tags", func(t *testing.T) { + template := "This is $1 and this is $2 but not this $3." + section_items := []string{"ok", "ok"} + want := "This is ok and this is ok but not this $3." + + got := replace_headers(template, section_items) + if got != want { + t.Errorf("replace_headers failure case failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("nothing - should rm %ITEMS% if section_items is empty", func(t *testing.T) { + template := "This is $1 and this is $2 but not this." + section_items := []string{} + want := "This is $1 and this is $2 but not this." + got := replace_headers(template, section_items) + if got != want { + t.Errorf("replace_headers 'nothing' case failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) +} + +func TestReplaceItems(t *testing.T) { + t.Run("happy path - should replace %ITEMS% with formatted items", func(t *testing.T) { + template := "Start\n%ITEMS% End" + section_items := []string{"Item 1", "Item 2", "Item 3"} + keyword := []string{"1", "2"} + want := `Start +\resumeItem{Item \textbf{1}} +\resumeItem{Item \textbf{2}} +\resumeItem{Item 3} + End` + + + got := replace_items(template, section_items,keyword) + if got != want { + t.Errorf("replace_items happy path failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("failure - should handle malformed template with extra %ITEMS% tags", func(t *testing.T) { + template := "Start\n%ITEMS%Middle\n%ITEMS%\nEnd" + section_items := []string{"Single Item"} + want := "Start\n\\resumeItem{Single Item}\nMiddle\n%ITEMS%\nEnd" + keyword := []string{"1", "2"} + + got := replace_items(template, section_items,keyword) + if got != want { + t.Errorf("replace_items failure case failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) + + t.Run("nothing - should rm %ITEMS% if section_items is empty", func(t *testing.T) { + template := "Start\n%ITEMS%\nEnd" + section_items := []string{} + keyword := []string{"1", "2"} + want := "Start\n\nEnd" + got := replace_items(template, section_items,keyword) + if got != want { + t.Errorf("replace_items 'nothing' case failed;\nwant:\n%s\ngot:\n%s", want, got) + } + }) +} + +func TestSanitize(t *testing.T) { + t.Run("happy path - should sanitize special characters", func(t *testing.T) { + got := "100% **bold text** and *italic text*" + expected := "100\\% \\textbf{bold text} and \\emph{italic text}" + result := sanitize(got) + if result != expected { + t.Errorf("Sanitize happy path failed; expected:\n%s\ngot:\n%s", expected, result) + } + }) + + t.Run("failure - should handle unmatched patterns gracefully", func(t *testing.T) { + got := "**bold text with *unmatched italic**" + expected := "\\textbf{bold text with *unmatched italic}" + result := sanitize(got) + if result != expected { + t.Errorf("Sanitize failure case failed; expected:\n%s\ngot:\n%s", expected, result) + } + }) + + t.Run("nothing - should change nothing", func(t *testing.T) { + got := "Just a plain string" + expected := "Just a plain string" + result := sanitize(got) + if result != expected { + t.Errorf("Sanitize 'nothing' case failed; expected:\n%s\ngot:\n%s", expected, result) + } + }) +} + +func TestApplyInfoToTemplate(t *testing.T) { + template := "%VARS%" + params := core.Params{ + Info: struct { + Name string `yaml:"name"` + FirstName string `yaml:"firstname"` + Number string `yaml:"number"` + Mail string `yaml:"mail"` + GitHub string `yaml:"github"` + LinkedIn string `yaml:"linkedin"` + }{ + Name: "John Doe", + FirstName: "John", + Number: "12345", + Mail: "john.doe@example.com", + GitHub: "https://github.com/johndoe", + LinkedIn: "https://linkedin.com/in/johndoe", + }, + Variante: map[string][]string{}, + } + want := `\def\Name{John Doe} +\def\FirstName{John} +\def\Number{12345} +\def\Mail{john.doe@example.com} +\def\GitHub{https://github.com/johndoe} +\def\LinkedIn{https://linkedin.com/in/johndoe}` + got := ApplyInfoToTemplate(template, params) + + if removeWhitespace(got) != removeWhitespace(want) { + t.Errorf("expected:\n%s\ngot:\n%s", want, got) + } +} + +func TestGetListOfTemplate(t *testing.T) { + root := "testdata" + templateDir := filepath.Join(root, "assets", "latex", "output") + err := os.MkdirAll(templateDir, os.ModePerm) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + defer os.RemoveAll(root) + + files := []string{"foo.tex", "bar.tex", "garbage"} + for _, file := range files { + f, err := os.Create(filepath.Join(templateDir, file)) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + f.Close() + } + + got, err := getListOfTemplate(root) + if err != nil { + t.Fatalf("getListOfTemplate returned an error: %v", err) + } + expected := files + if len(got) != len(expected)-1 { //Should ommit garbage + t.Errorf("expected %d files, got %d", len(expected), len(got)) + } + for _, filePath := range got { + _, err := os.Stat(filePath) + if err != nil { + t.Errorf("expected path %s not found: %v", filePath, err) + } + } +} diff --git a/internal/adapters/output/latex_writer.go b/internal/adapters/output/latex_writer.go new file mode 100644 index 0000000..525fb98 --- /dev/null +++ b/internal/adapters/output/latex_writer.go @@ -0,0 +1,154 @@ +package output + +import ( + "anemon/internal/core" + "errors" + "log/slog" + "fmt" + "os" + "reflect" + "regexp" + "strings" +) + +const NB_REPEAT = 1 + +type LatexReader struct{} +type LatexProccesor struct{} +var REG_EXTRACT_LANG_FROM_NAME = regexp.MustCompile(`-(.*?)-`) + +// Write the template file in the assets directory +func (*LatexProccesor) MakeNewTemplate(path string, template string, name string) error { + lang := REG_EXTRACT_LANG_FROM_NAME.FindStringSubmatch(name) + san_template := sanitize_template(template,lang[1]) + err := os.WriteFile(path+"/assets/latex/output/"+name, + []byte(san_template), 0644) + return err +} + +// Read the template file in the assets directory from the root dir and apply the params given to it +func (*LatexReader) ReadCVTemplate(root string, params core.Params) (string, error) { + file, err := os.ReadFile(root + "/assets/latex/template/template.tex") + if err != nil { + return "", err + } + return ApplyInfoToTemplate(string(file), params), nil +} + +// Apply general information(name, mail...) to a template +func ApplyInfoToTemplate(template string, params core.Params) string { + var varsBuilder strings.Builder + infoValue := reflect.ValueOf(params.Info) + for i := 0; i < infoValue.NumField(); i++ { + field := infoValue.Type().Field(i) + fieldValue := infoValue.Field(i) + varsBuilder.WriteString("\\def\\" + field.Name + "{" + fieldValue.String() + "}\n") + } + return replaceVars(template, varsBuilder.String()) +} + +// Apply a section to a section type on a latex template +func (*LatexProccesor) ApplySectionToTemplate(template string, headers []string, + item []string, section string, keyword []string) (string, error) { + if len(section) < 2 { + return "", errors.New("Don't know type " + section) + } + section = strings.ToUpper(string(section[0])) + section[1:] + switch { + case section == "Professional": + template = replaceWithSectionTemplate(template, ProfessionalTemplate, + headers, item,keyword) + case section == "Project": + template = replaceWithSectionTemplate(template, ProjectTemplate, + headers, item,keyword) + case section == "Education": + template = replaceWithSectionTemplate(template, EducationTemplate, + headers, nil,nil) + case section == "Skill": + template = replaceWithSectionTemplate(template, SkillTemplate, + headers, nil,keyword) + default: + slog.Warn("Don't know type " + section) + return "", errors.New("Don't know type " + section) + } + return template, nil +} + +// Replace with the template defined in the template_sections.go const +func replaceWithSectionTemplate(template string, SectionTemplate TemplateStruct, headers []string, items []string,keywords []string) string { + updated_template := strings.Replace(template, SectionTemplate.hook, + SectionTemplate.hook+replace_headers(SectionTemplate.template, headers), 1) + if items != nil { + updated_template = replace_items(updated_template, items, keywords) + } + return updated_template +} + +// Replace the %vars$ with the vars +func replaceVars(template string, vars string) string { + updated_template := strings.Replace(template, "%VARS%", vars, 1) + return updated_template +} + +// Search and replace the headers in the template by their replacement +func replace_headers(sec_template string, replacements []string) string { + for i := 0; i < len(replacements); i++ { + position := fmt.Sprintf("$%d", i+1) + sec_template = strings.Replace(sec_template, + position, replacements[i], 1) + } + return sanitize(sec_template) +} + +//replace the %item% keyword in the template with the item prepared for the CV +func replace_items(template string, o_section_items []string, keywords []string) string { + section_items := emph_keyword(o_section_items,keywords) + items := "" + for _, item := range section_items { + items += strings.Replace(single_item_template, "%ITEM%", item, 1) + } + template = strings.Replace(template, + "%ITEMS%", items, 1) + return sanitize(template) +} + +//Take a list of items and return them with emphasis on the keyword +func emph_keyword(items []string, keywords []string) []string{ + if keywords == nil{ return items } + + res := make([]string, len(items)) + for i_i,item := range items{ + new_item := item + for _,keyword := range keywords{ + new_item = strings.Replace(new_item, keyword,`\textbf{`+keyword+`}`,NB_REPEAT) + } + res[i_i]=new_item + } + return res +} + +// Sanitize the template section with they version in the choosen language +func sanitize_template(template string,lang string) string { + for english, translated := range translations[strings.ToUpper(lang)] { + template = strings.ReplaceAll(template, english, translated) + } + return template +} + +// Sanitize the special charactere +func sanitize(template string) string { + replacements := []struct { + pattern string + replacement string + }{ + {`([0-9])\%`, `$1\%`}, + {`\*\*(.*?)\*\*`, `\textbf{$1}`}, + {`\*(.*?)\*`, `\emph{$1}`}, + //{`\[(.*?)\]\((.*?)\)`, `\href{$2}{$1}`}, + } + for _, r := range replacements { + re := regexp.MustCompile(r.pattern) + template = re.ReplaceAllString(template, r.replacement) + } + return template +} diff --git a/internal/adapters/output/template_sections.go b/internal/adapters/output/template_sections.go new file mode 100644 index 0000000..5544d3f --- /dev/null +++ b/internal/adapters/output/template_sections.go @@ -0,0 +1,119 @@ +package output + +type TemplateStruct struct { + template string + hook string +} + +var ( + single_item_template = "\\resumeItem{%ITEM%}\n" + + ProfessionalTemplate = TemplateStruct{ + template: ` +\resumeSubheading + {$1}{$2} + {\href{$3}{$4}}{ } +\resumeItemListStart + %ITEMS% +\resumeItemListEnd +`, + hook: "%EXPERIENCE_SECTIONS%", + } + + ProjectTemplate = TemplateStruct{ + template: ` +\resumeProjectHeading +{\textbf{$1} | \emph{$2 \href{$3}{\faIcon{github}}}}{} +\resumeItemListStart + %ITEMS% +\resumeItemListEnd +`, + hook: "%PROJECTS_SECTIONS%", + } + + EducationTemplate = TemplateStruct{ + template: ` +\resumeSubheading +{\href{$2}{$1}}{} +{$3}{$4} +`, + hook: "%EDUCATION_SECTIONS%", + } + + SkillTemplate = TemplateStruct{ + template: ` +\item \textbf{$1}{: $2} \\ +`, + hook: "%SKILL_SECTIONS%", + } +) + +// For section translation +var translations = map[string]map[string]string{ + "FR": { + "Skill": "Compétence", + "Education": "Éducation", + "Professional": "Professionnel", + "Project": "Projet", + }, + "DE": { + "Skill": "Fähigkeit", + "Education": "Bildung", + "Professional": "Beruflich", + "Project": "Projekt", + }, + "NL": { + "Skill": "Vaardigheid", + "Education": "Onderwijs", + "Professional": "Professioneel", + "Project": "Project", + }, + "SP": { + "Skill": "Habilidad", + "Education": "Educación", + "Professional": "Profesional", + "Project": "Proyecto", + }, + "IT": { + "Skill": "Competenza", + "Education": "Istruzione", + "Professional": "Professionale", + "Project": "Progetto", + }, + "PT": { + "Skill": "Habilidade", + "Education": "Educação", + "Professional": "Profissional", + "Project": "Projeto", + }, + "SE": { + "Skill": "Färdighet", + "Education": "Utbildning", + "Professional": "Professionell", + "Project": "Projekt", + }, + "NO": { + "Skill": "Ferdighet", + "Education": "Utdanning", + "Professional": "Profesjonell", + "Project": "Prosjekt", + }, + "FI": { + "Skill": "Taito", + "Education": "Koulutus", + "Professional": "Ammattilainen", + "Project": "Hanke", + }, + "PL": { + "Skill": "Umiejętność", + "Education": "Edukacja", + "Professional": "Profesjonalny", + "Project": "Projekt", + }, + "RU": { + "Skill": "Навыки", + "Education": "Образование", + "Professional": "Профессионал", + "Project": "Проект", + }, +} diff --git a/internal/core/generate.go b/internal/core/generate.go new file mode 100644 index 0000000..5aacf5b --- /dev/null +++ b/internal/core/generate.go @@ -0,0 +1,269 @@ +package core + +import ( + "fmt" + "log/slog" + "sort" + "strings" + "sync" +) + +var TresholdPageOverFlow struct { + value int + mutex sync.Mutex +} + +type CVService struct { + root string + source Source + paramsSource SourceParams + templateReader TemplateReader + templateProcessor TemplateProcessor + compiler Compiler +} + +type BuilderService struct { + root string + source Source + paramsSource SourceParams + templateReader TemplateReader + templateProcessor TemplateProcessor + compiler Compiler +} + +const DO_NOT_RM_EDUCATION = true + +// generate the templates for the cvs defined in the assets directory +func (s *CVService) GenerateTemplates() error { + slog.Info("--Generating CVs--") + cvs, err := s.source.GetCVsFrom(s.root) + if err != nil { + return fmt.Errorf("failed to get CVs: %w", err) + } + params, err := s.paramsSource.GetParamsFrom(s.root) + if err != nil { + return fmt.Errorf("failed to get parameters: %w", err) + } + generiqueTemplate, err := s.templateReader.ReadCVTemplate(s.root, params) + if err != nil { + return fmt.Errorf("failed to read generic template: %w", err) + } + if err := s.generateAllCVs(cvs, params, generiqueTemplate, false); err != nil { + return err + } + return s.compileWithOverflowHandling(cvs, params, generiqueTemplate) +} + +// Compile the CV into PDF, and if they are too long regenrate theme with one less section +func (s *CVService) compileWithOverflowHandling(cvs []CV, params Params, template string) error { + threshold := GetOverflowThreshold() + maxNbPage, err := s.compiler.CompileTemplate(s.root) + if err != nil { + return fmt.Errorf("failed to compile template: %w", err) + } + for maxNbPage > threshold { + slog.Warn("Page overflow detected; adjusting layout and regenerating CVs") + if err := s.generateAllCVs(cvs, params, template, true); err != nil { + return err + } + maxNbPage, err = s.compiler.CompileTemplate(s.root) + if err != nil { + return fmt.Errorf("failed to recompile template after adjustment: %w", err) + } + } + return nil +} + +// Use the the slice of CV to write in the output directory the new CV template +func (s *CVService) generateAllCVs(cvs []CV, params Params, template string, adjustLayout bool) error { + for _, cv := range cvs { + if err := generateCVFrom(cv, params, s.root, template, s.templateProcessor, adjustLayout); err != nil { + return fmt.Errorf("failed to generate CV: %w", err) + } + } + return nil +} + +// generate a template for the cv with the given params +func generateCVFrom(cv CV, params Params, root string, + template string, processor TemplateProcessor, shouldBeShorter bool) error { + var err error + if len(params.Variante) == 0 { //if no variante create simple CV + params.Variante = map[string][]string{"simple": nil} + } + for vari, keywords := range params.Variante { + cvName := "CV-" + cv.Lang + "-" + vari + ".tex" + slog.Info("Generating for:" + cvName) + cvTemplate := template + if shouldBeShorter { + removeLowestParagraphe(&cv, keywords) + } + for _, section := range cv.Sections { + for _, paragraph := range section.Paragraphes { + headers := []string{paragraph.H1, paragraph.H2, + paragraph.H3, paragraph.H4} + items := paragraph.Items + sortByScore(items, keywords) + cvTemplate, err = processor.ApplySectionToTemplate( + cvTemplate, headers, items, section.Title, keywords) + if err != nil { + return err + } + } + err = processor.MakeNewTemplate(root, cvTemplate, cvName) + if err != nil { + return err + } + } + } + return nil +} + +// Remove the lowest paragraph in the project and experience sections +func removeLowestParagraphe(cv *CV, keywords []string) { + if len(cv.Sections) == 0 { + return + } + s_i,p_i := getLowestParagraphAcrossSections(*cv, keywords) + if s_i<0 || p_i < 0{ + slog.Warn("No paragraphe to remove found") + return + } + cv.Sections[s_i].Paragraphes = append(cv.Sections[s_i].Paragraphes[:p_i], cv.Sections[s_i].Paragraphes[p_i+1:]...) +} + +// Return the index of the paragraph across all sections with the lowest score +func getLowestParagraphAcrossSections(cv CV, keywords []string) (s_i int, p_i int) { + minScore := int(^uint(0) >> 1) + s_i,p_i = -1,-1 + for secIdx, section := range cv.Sections { + slog.Warn(section.Title) + if section.Title == "education" && DO_NOT_RM_EDUCATION{ + continue + } + parIdx := getLowestParagraphe(section, keywords) + if parIdx >= 0 { + currentScore := getScoreParagraphe(section.Paragraphes[parIdx], keywords) + if currentScore < minScore { + minScore, s_i, p_i = currentScore, secIdx, parIdx + } + } + } + return s_i, p_i +} + +// Return the index of the paragraph from the section with the lowest score +func getLowestParagraphe(section Section, keywords []string) int { + return getLowestIndex(section.Paragraphes, keywords, getScoreParagraphe) +} + +// generic function to get the index of the element with the lowest score +// TODO ? an interface to avoid the any ? +func getLowestIndex[T any](items []T, keywords []string, getScore func(T, []string) int) int { + if len(items)==0{ + return -1 + } + min_score := getScore(items[0], keywords) + min_idx := 0 + for idx, item := range items[1:] { + current_score := getScore(item, keywords) + if current_score < min_score { + min_idx = idx + 1 + min_score = current_score + } + } + return min_idx +} + +// Get items of a single paragraph and sum they score to get global score of the items +func getScoreParagraphe(paragraph Paragraphe, keywords []string) int { + res := 0 + for _, item := range paragraph.Items { + res += getScore(item, keywords) + } + return res +} + +// Sorte a slice of items by the number of keyword +// +// The sort is done in ascending order as the section append work like a stack(Lifo) +func sortByScore(items []string, keywords []string) { + sort.Slice(items, func(i, j int) bool { + return getScore(items[i], keywords) > getScore(items[j], keywords) + }) +} + +// take and item and a list of keyword and return the number of keyword inside the item +func getScore(item string, keywords []string) int { + score := 0 + for _, keyword := range keywords { + score += strings.Count(item, keyword) + } + return score +} + +//Get all info of the cvs and prompt them in the stdout +func GetInfoAllCvs(root string, source Source){ + cvs, err := source.GetCVsFrom(root) + if err != nil{ + slog.Warn(err.Error()) + } + for _,cv := range cvs{ + cv.Print() + } +} + +// Set the threshold value dynamically +func SetOverflowThreshold(newThreshold int) { + TresholdPageOverFlow.mutex.Lock() + defer TresholdPageOverFlow.mutex.Unlock() + TresholdPageOverFlow.value = newThreshold +} + +// Get the current threshold value +func GetOverflowThreshold() int { + TresholdPageOverFlow.mutex.Lock() + defer TresholdPageOverFlow.mutex.Unlock() + return TresholdPageOverFlow.value +} + +func init() { + TresholdPageOverFlow.value = 1 +} + +//--Builder-- + +func (cv *BuilderService) SetRoot(root string) { + cv.root = root +} + +func (cv *BuilderService) SetSource(source Source) { + cv.source = source +} + +func (cv *BuilderService) SetParamsSource(paramsSource SourceParams) { + cv.paramsSource = paramsSource +} + +func (cv *BuilderService) SetTemplateReader(templateReader TemplateReader) { + cv.templateReader = templateReader +} + +func (cv *BuilderService) SetTemplateProcessor(templateProcessor TemplateProcessor) { + cv.templateProcessor = templateProcessor +} + +func (cv *BuilderService) SetCompiler(compiler Compiler) { + cv.compiler = compiler +} + +func (s *BuilderService) GetService() CVService { + return CVService{ + root: s.root, + source: s.source, + paramsSource: s.paramsSource, + templateReader: s.templateReader, + templateProcessor: s.templateProcessor, + compiler: s.compiler, + } +} diff --git a/internal/core/generate_test.go b/internal/core/generate_test.go new file mode 100644 index 0000000..6be00dd --- /dev/null +++ b/internal/core/generate_test.go @@ -0,0 +1,308 @@ +package core + +import ( + "reflect" + "testing" +) + +type MockParamsSource struct { + Params Params + Err error +} + +func (s *MockParamsSource) GetParamsFrom(root string) (Params, error) { + if s.Err != nil { + return s.Params, s.Err + } + return s.Params, nil +} + +type MockCompiler struct{} + +func (c *MockCompiler) CompileTemplate(root string) (int, error) { return 0, nil } + +type MockSource struct { + CVs []CV + Err error +} + +func (s *MockSource) GetCVsFrom(root string) ([]CV, error) { + if s.Err != nil { + return nil, s.Err + } + return s.CVs, nil +} + +type MockTemplateReader struct { + Template string + Err error +} + +func (r *MockTemplateReader) ReadCVTemplate(path string, params Params) (string, error) { + if r.Err != nil { + return "", r.Err + } + return r.Template, nil +} + +type MockTemplateProcessor struct { + AppliedTemplates []string + GeneratedFiles map[string]string + ApplyErr error + MakeErr error +} + +func (p *MockTemplateProcessor) MakeNewTemplate(path string, template string, name string) error { + if p.MakeErr != nil { + return p.MakeErr + } + p.GeneratedFiles[name] = template + return nil +} + +func (p *MockTemplateProcessor) ApplySectionToTemplate(template string, headers []string, items []string, section string, keywords []string) (string, error) { + if p.ApplyErr != nil { + return "", p.ApplyErr + } + result := template + " | Section: " + section + " | Headers: " + headers[0] + ", " + headers[1] + ", " + headers[2] + ", " + headers[3] + for _, item := range items { + result += " | Item: " + item + } + p.AppliedTemplates = append(p.AppliedTemplates, result) + return result, nil +} + +func TestGenerateTemplates(t *testing.T) { + root := "testRoot" + baseTemplate := "base template content" + params := Params{ + Info: struct { + Name string `yaml:"name"` + FirstName string `yaml:"firstname"` + Number string `yaml:"number"` + Mail string `yaml:"mail"` + GitHub string `yaml:"github"` + LinkedIn string `yaml:"linkedin"` + }{ + Name: "Doe", + FirstName: "John", + Number: "12345", + Mail: "john.doe@example.com", + GitHub: "johndoe", + LinkedIn: "john-doe-linkedin", + }, + Variante: map[string][]string{ + "optionA": {"value1", "value2"}, + "optionB": {"value3"}, + }, + } + + cv := CV{ + Lang: "EN", + Sections: []Section{ + { + Title: "Work Experience", + Paragraphes: []Paragraphe{ + { + H1: "Job Title", + H2: "Company", + H3: "Location", + H4: "Date", + Items: []string{"Managed projects", "Led team"}, + }, + }, + }, + }, + } + + t.Run("When giving one language and two variante should generate two cv in the language", func(t *testing.T) { + source := &MockSource{CVs: []CV{cv}} + paramsSource := &MockParamsSource{Params: params} + templateReader := &MockTemplateReader{Template: baseTemplate} + templateProcessor := &MockTemplateProcessor{GeneratedFiles: make(map[string]string)} + compiler := &MockCompiler{} + var builder BuilderService + + builder.SetRoot(root) + builder.SetSource(source) + builder.SetParamsSource(paramsSource) + builder.SetTemplateReader(templateReader) + builder.SetTemplateProcessor(templateProcessor) + builder.SetCompiler(compiler) + + service := builder.GetService() + + err := service.GenerateTemplates() + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(templateProcessor.GeneratedFiles) != 2 { + t.Fatalf("expected 2 generated file, got %d", len(templateProcessor.GeneratedFiles)) + } + + generatedTemplate, exists := templateProcessor.GeneratedFiles["CV-EN-optionA.tex"] + if !exists { + t.Fatalf("expected generated file 'CV-EN-optionA.tex' to exist") + } + + expectedContent := baseTemplate + " | Section: Work Experience | Headers: Job Title, Company, Location, Date | Item: Managed projects | Item: Led team" + if !reflect.DeepEqual(generatedTemplate, expectedContent) { + t.Errorf("expected generated template content %v, got %v", expectedContent, generatedTemplate) + } + }) + + t.Run("When giving one language and zero info or variante should generate one cv in the language", func(t *testing.T) { + source := &MockSource{CVs: []CV{cv}} + paramsSource := &MockParamsSource{Params: Params{}} + templateReader := &MockTemplateReader{Template: baseTemplate} + templateProcessor := &MockTemplateProcessor{GeneratedFiles: make(map[string]string)} + compiler := &MockCompiler{} + var builder BuilderService + + builder.SetRoot(root) + builder.SetSource(source) + builder.SetParamsSource(paramsSource) + builder.SetTemplateReader(templateReader) + builder.SetTemplateProcessor(templateProcessor) + builder.SetCompiler(compiler) + + service := builder.GetService() + err := service.GenerateTemplates() + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(templateProcessor.GeneratedFiles) != 1 { + t.Fatalf("expected 1 generated file, got %d", len(templateProcessor.GeneratedFiles)) + } + + generatedTemplate, exists := templateProcessor.GeneratedFiles["CV-EN-simple.tex"] + if !exists { + t.Fatalf("expected generated file 'CV-EN-simple.tex' to exist") + } + + expectedContent := baseTemplate + " | Section: Work Experience | Headers: Job Title, Company, Location, Date | Item: Managed projects | Item: Led team" + if !reflect.DeepEqual(generatedTemplate, expectedContent) { + t.Errorf("expected generated template content %v, got %v", expectedContent, generatedTemplate) + } + }) + +} + +func TestGetScore(t *testing.T) { + tests := []struct { + item string + keywords []string + expected int + }{ + {item: "foo bar baz", keywords: []string{"foo", "bar", "baz"}, expected: 3}, + {item: "foo qux", keywords: []string{"foo", "bar", "baz"}, expected: 1}, + {item: "patate douce", keywords: []string{"foo", "bar", "baz"}, expected: 0}, + {item: "Foo Bar Baz", keywords: []string{"foo", "bar", "baz"}, expected: 0}, + {item: "foo bar baz", keywords: []string{}, expected: 0}, + {item: "", keywords: []string{"foo", "bar", "baz"}, expected: 0}, + {item: "foo bar foo baz", keywords: []string{"foo", "foo bar"}, expected: 3}, + } + + for _, tt := range tests { + t.Run(tt.item, func(t *testing.T) { + score := getScore(tt.item, tt.keywords) + if score != tt.expected { + t.Errorf("for item=%q and keywords=%v, expected %d, got %d", tt.item, tt.keywords, tt.expected, score) + } + }) + } +} + +func TestSortByScore(t *testing.T) { + keywords := []string{"foo", "bar"} + tests := []struct { + items []string + expected []string + }{ + { + items: []string{" with foo", " with foo and bar", " with neither", " with bar"}, + expected: []string{" with foo and bar", " with foo", " with bar", " with neither"}, + }, + + { + items: []string{" with foo bar foo bar", " with foo", " with bar bar bar", " with foo bar"}, + expected: []string{" with foo bar foo bar", " with bar bar bar", " with foo bar", " with foo"}, + }, + { + items: []string{" without keywords", " with bar", "Another without keywords"}, + expected: []string{" with bar", " without keywords", "Another without keywords"}, + }, + } + + for _, tt := range tests { + items := make([]string, len(tt.items)) + copy(items, tt.items) + sortByScore(items, keywords) + + for i := range items { + if items[i] != tt.expected[i] { + t.Errorf("after sorting, expected %v but got %v", tt.expected, items) + } + } + } +} + +func TestGetLowestParagraphe(t *testing.T) { + keywords := []string{"skills", "experience", "project"} + tests := []struct { + section Section + expected int + }{ + { + section: Section{ + Title: "Work Experience", + Paragraphes: []Paragraphe{ + {H1: "Experience 1", Items: []string{"skills", "project"}}, + {H1: "Experience 2", Items: []string{"experience", "skills"}}, + {H1: "Experience 3", Items: []string{"project"}}, + }, + }, + expected: 2, + }, + { + section: Section{ + Title: "Projects", + Paragraphes: []Paragraphe{ + {H1: "Project A", Items: []string{"project", "skills"}}, + {H1: "Project B", Items: []string{"skills", "experience"}}, + {H1: "Project C", Items: []string{"project", "experience"}}, + }, + }, + expected: 0, + }, + { + section: Section{ + Title: "Skills", + Paragraphes: []Paragraphe{ + {H1: "Skillset 1", Items: []string{"skills", "tools", "project"}}, + {H1: "Skillset 2", Items: []string{"experience", "skills"}}, + }, + }, + expected: 0, + }, + { + section: Section{ + Title: "Summary", + Paragraphes: []Paragraphe{ + {H1: "Summary", Items: []string{}}, + {H1: "Overview", Items: []string{}}, + }, + }, + expected: 0, + }, + } + + for _, tt := range tests { + result := getLowestParagraphe(tt.section, keywords) + if result != tt.expected { + t.Errorf("expected index %v but got %v", tt.expected, result) + } + } +} diff --git a/internal/core/ports.go b/internal/core/ports.go new file mode 100644 index 0000000..1d8187a --- /dev/null +++ b/internal/core/ports.go @@ -0,0 +1,104 @@ +package core + +import ( + "fmt" + "strings" +) + +type ICVservice interface { + generateTemplates(root string, source Source, templateReader TemplateReader, templateProcessor TemplateProcessor) error +} + +type Compiler interface { + CompileTemplate(root string) (int, error) +} + +type SourceParams interface { + GetParamsFrom(root string) (Params, error) +} + +type Source interface { + GetCVsFrom(root string) ([]CV, error) +} + +type TemplateReader interface { + ReadCVTemplate(root string, params Params) (string, error) +} + +type TemplateProcessor interface { + MakeNewTemplate(path string, template string, name string) error + ApplySectionToTemplate(template string, headers []string, item []string, section string, keyword []string) (string, error) +} + +// CV with Language and Sections +type CV struct { + Lang string + Sections []Section +} + +type Section struct { + Title string + Paragraphes []Paragraphe +} + +type Paragraphe struct { + H1 string + H2 string + H3 string + H4 string + Items []string +} + +type Params struct { + Info struct { + Name string `yaml:"name"` + FirstName string `yaml:"firstname"` + Number string `yaml:"number"` + Mail string `yaml:"mail"` + GitHub string `yaml:"github"` + LinkedIn string `yaml:"linkedin"` + } `yaml:"info"` + Variante map[string][]string `yaml:"variante"` +} + +// Print Method for CV +func (cv *CV) Print() { + fmt.Println("CV Language: " + cv.Lang) + fmt.Println(strings.Repeat("=", 40)) + fmt.Printf("With %d Sections\n", len(cv.Sections)) + for _, section := range cv.Sections { + fmt.Printf("Section: %s\n", section.Title) + fmt.Println(strings.Repeat("-", 40)) + for _, p := range section.Paragraphes { + if p.H1 != "" { + fmt.Printf("H1: %s\n", p.H1) + } else { + fmt.Printf("No H1") + } + if p.H2 != "" { + fmt.Printf(" H2: %s\n", p.H2) + } else { + fmt.Printf("No H2") + } + if p.H3 != "" { + fmt.Printf(" H3: %s\n", p.H3) + } else { + fmt.Printf("No H3") + } + if p.H4 != "" { + fmt.Printf(" H4: %s\n", p.H4) + } else { + fmt.Printf("No H4") + } + + if len(p.Items) > 0 { + fmt.Println(" Items:") + for _, item := range p.Items { + fmt.Printf(" - %s\n", item) + } + } + fmt.Println() + } + fmt.Println() + } +} diff --git a/internal/markup_languages/latex.go b/internal/markup_languages/latex.go deleted file mode 100644 index 6a51402..0000000 --- a/internal/markup_languages/latex.go +++ /dev/null @@ -1,118 +0,0 @@ -package markuplanguages - -import ( - "errors" - "regexp" - "fmt" - "os" - "strings" -) - -type SectionName string - -//Read the template file in the assets directory -func read_template(path string)(string,error) { - file, err := os.ReadFile(path+"/assets/latex/template/template.tex") - if err != nil { - return "", err - } - return string(file), nil -} - -//Write the template file in the assets directory -func writeTemplate(path string, template string, name string)error{ - err := os.WriteFile(path+"/assets/latex/output/"+name, - []byte(template), 0644) - return err -} - -//Create a new empty template in the output dir -func Init_output(name string, root_path string)error{ - template,err := read_template(root_path) - if err != nil { - return err - } - err = writeTemplate(root_path, template, name+".tex") - return err -} - -//Apply a section to a section type on a latex template -func ApplyToSection(section Section, section_type string, output_path string)(string,error){ - replacements := []string{section.first, section.second, section.third, section.fourth} - b_template, err := os.ReadFile(output_path) - //nolint:all - section_type = strings.Title(section_type) - if err != nil { - return "", err - } - template := string(b_template) - switch{ - - case section_type == "Professional": - template = strings.Replace(template,"%EXPERIENCE_SECTIONS%", - "%EXPERIENCE_SECTIONS%\n"+replace_param(prof_template,NB_P_PROF,replacements),1) - template = replace_items(template, section.description) - - case section_type == "Project": - template = strings.Replace(template,"%PROJECTS_SECTIONS%", - "%PROJECTS_SECTIONS%\n"+replace_param(proj_template,NB_P_PROJ,replacements),1) - template = replace_items(template, section.description) - - case section_type == "Education": - template = strings.Replace(template,"%EDUCATION_SECTIONS%", - "%EDUCATION_SECTIONS%\n"+replace_param(edu_template,NB_P_EDU,replacements),1) - - case section_type == "Skill"://TODO https://github.com/Theo-Hafsaoui/Anemon/issues/1 - template = strings.Replace(template,"%SKILL_SECTIONS%", - "%SKILL_SECTIONS%\n"+replace_param(sk_template,NB_P_SK,replacements),1) - default: - return "",errors.New("Don't know type "+section_type) - } - path_name := strings.Split(output_path, "/assets/latex/output/") - if len(path_name) == 1 { - return template,errors.New("Trying to save outside of output file, at "+output_path) - } - if err != nil{ - return "",err - } - err = writeTemplate(path_name[0],template,path_name[1]) - return template,err -} - -//Search and replace the number in range of `nb_params` by their replacement -func replace_param(template string, nb_params int, replacements []string)string{ - for i := 0; i < nb_params; i++ { - position := fmt.Sprintf("$%d", i+1) - template = strings.Replace(template, - position, replacements[i], 1) - } - return sanitize(template) -} - -func replace_items(template string, section_items []string)string{ - items := "" - for _,item := range section_items{ - items += strings.Replace(pro_item,"%ITEM%",item,1) - } - template = strings.Replace(template, - "%ITEMS%", items, 1) - return sanitize(template) -} - -//Sanitize the special charactere -func sanitize(template string)(string){ - replacements := []struct { - pattern string - replacement string - }{ - {`[0-9]\%`, `\\%`}, - {`\*\*(.*?)\*\*`, `\textbf{$1}`}, - {`\*(.*?)\*`, `\emph{$1}`}, - //{`\[(.*?)\]\((.*?)\)`, `\href{$2}{$1}`}, - } - for _, r := range replacements { - re := regexp.MustCompile(r.pattern) - template = re.ReplaceAllString(template, r.replacement) - } - return template -} diff --git a/internal/markup_languages/latex_test.go b/internal/markup_languages/latex_test.go deleted file mode 100644 index 425992b..0000000 --- a/internal/markup_languages/latex_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package markuplanguages - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - - -func TestIO(t *testing.T) { -t.Run("should read the template in assets", func (t *testing.T) { - dir := filepath.Join("../../assets", "latex", "template") - templateFile := filepath.Join(dir, "template.tex") - backupFile := filepath.Join(dir, "save.tex") - if _, err := os.Stat(templateFile); err == nil { - err = os.Rename(templateFile, backupFile) - if err != nil { - t.Fatalf("Failed to rename template.tex to save.tex: %v", err) - } - } - err := os.WriteFile(templateFile, []byte("Hello World"), 0644) - if err != nil { - t.Fatalf("Failed to write file: %v", err) - } - content, err := read_template("../../") - if err != nil { - t.Fatalf("Failed to read file: %v", err) - } - if content != "Hello World" { - t.Fatalf("Expected 'Hello World', got '%s'", content) - } - err = os.Remove(templateFile) - if err != nil { - t.Fatalf("Failed to remove file: %v", err) - } - if _, err := os.Stat(backupFile); err == nil { - err = os.Rename(backupFile, templateFile) - if err != nil { - t.Fatalf("Failed to rename save.tex back to template.tex: %v", err) - } - } -}) - -t.Run("should write a new template in output", func (t *testing.T) { - err := writeTemplate("../../","Hello, world", "hello.tex") - if err != nil { - t.Fatalf("Failed to write file: %v", err) - } - err = os.Remove("../../assets/latex/output/hello.tex") - if err != nil { - t.Fatalf("Failed to remove file: %v", err) - } -}) - -t.Run("should apply a section in output cv", func (t *testing.T) { - tests := []struct { - name string - section Section - sectionType string - want string - }{ - { - name: "Professional Section", - section: Section{ - first: "first", - second: "second", - third: "third", - fourth: "fourth", - description: []string{"item1", "item2"}, - }, - sectionType: "Professional", - want: `\resumeSubheading - {first}{second} - {\href{third}{fourth}}{ } -\resumeItemListStart - \resumeItem{item1} -\resumeItem{item2}`, - }, - { - name: "Project Section", - section: Section{ - first: "first", - second: "second", - third: "third", - description: []string{"item1", "item2"}, - }, - sectionType: "Project", - want: `\resumeProjectHeading -{\textbf{first} | \emph{second \href{third}{\faIcon{github}}}}{} -\resumeItemListStart - \resumeItem{item1} -\resumeItem{item2}`, - }, - { - name: "Education Section", - section: Section{ - first: "first", - second: "second", - third: "third", - fourth: "fourth", - }, - sectionType: "Education", - want: `\resumeSubheading -{\href{second}{first}}{} -{third}{fourth}`, - }, - { - name: "Skill Section", - section: Section{ - first: "first", - second: "second", - third: "third", - fourth: "fourth", - }, - sectionType: "Skill", - want: `\textbf{first}{: second} \\`, - }, - } - err := Init_output("test-template","../..") - if err != nil{ - t.Fatalf("error when crating output template: %v", err) - } - _, err = ApplyToSection(tests[0].section, tests[0].sectionType,"../../assets/latex/template/test-template.tex" ) - if err == nil { - t.Fatalf("Creating a latex template outside of the output directory should result in an error") - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ApplyToSection(tt.section, tt.sectionType,"../../assets/latex/output/test-template.tex" ) - if err != nil { - t.Fatalf("error when applying template: %v", err) - } - if !strings.Contains(got,tt.want){ - t.Errorf("TestApplySection mismatch should contains:\n %s\n got: \n%s", tt.want, got[10:]) - } - }) - } -}) -} diff --git a/internal/markup_languages/markdown.go b/internal/markup_languages/markdown.go deleted file mode 100644 index 6c71251..0000000 --- a/internal/markup_languages/markdown.go +++ /dev/null @@ -1,75 +0,0 @@ -package markuplanguages - -import ( - "errors" - "regexp" - "fmt" - "strings" -) - -/* -Section represents a parsed Markdown section with up to four heading levels and a description. -*/ -type Section struct { - first string - second string - third string - fourth string - description []string -} - -func (s Section) String() string { - return fmt.Sprintf("1: %s\n2: %s\n3: %s\n4: %s\nitems: %v", - s.first,s.second,s.third,s.fourth,s.description) -} - -/* -Parse parses a Markdown-like `paragraph` into a `Section`, -extracting headings and description based on the number of leading hashtags or stars. -Returns an error if the format is invalid. -*/ -func Parse(paragraph string) (Section,error){ - section := Section{} - if len(strings.Split(paragraph, "\n\n")) > 1{ - return section, errors.New("Tried to parse mutiple paragraph into a single section") - } - hashtag_regex, _ := regexp.Compile("^#+") - wasASkill := false - for _, line := range strings.Split(strings.TrimRight(paragraph, "\n"), "\n") { - nb_hashtag := len(hashtag_regex.FindString(line)) - - if len(line) == 0{ - continue - } - - if wasASkill { - wasASkill = false - section.second= strings.TrimLeft(line,"- ") - } - - if nb_hashtag == 0 && string(line[0])=="*" && len(strings.Trim(line,"*")) == len(line) - 4 {//Trim should **tt** -> tt - section.first= strings.Trim(line,"*") - wasASkill = true - } - - - switch{ - case nb_hashtag>0 && line[nb_hashtag] != ' ': - return section, errors.New("Err: cannot parse this md line{"+line+"} # should be followed by space") - case nb_hashtag == 1: - section.first=line[nb_hashtag+1:] - case nb_hashtag == 2: - section.second=line[nb_hashtag+1:] - case nb_hashtag == 3: - section.third=line[nb_hashtag+1:] - case nb_hashtag == 4: - section.fourth=line[nb_hashtag+1:] - case nb_hashtag == 0 && len(line)>1: - items := strings.Split(line, "\n") - section.description = append(section.description, items...) - case nb_hashtag > 4: - return section, errors.New("Err: cannot parse this md line{"+line+"}") - } - } - return section, nil -} diff --git a/internal/markup_languages/markdown_test.go b/internal/markup_languages/markdown_test.go deleted file mode 100644 index 267455b..0000000 --- a/internal/markup_languages/markdown_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package markuplanguages - -import ( - "fmt" - "reflect" - "testing" -) - -func TestParse(t *testing.T) { - t.Run("Happy path should return the wanted struct", func (t *testing.T) { - input := ` -# Title -## Skill -### Date -#### Url -Item -Item2` - result, err := Parse(input) - if err != nil { - t.Fatalf("Unexecpted eroor :%s", err.Error()) - } - - first := "Title" - if result.first != first { - t.Fatalf("want %s got %s", first, result.first) - } - - - second := "Skill" - if result.second != second { - t.Fatalf("want %s got %s", second, result.second) - } - - third := "Date" - if result.third != third { - t.Fatalf("want %s got %s", third, result.third) - } - - fourth := "Url" - if result.fourth != fourth { - t.Fatalf("want %s got %s", fourth, result.fourth) - } - - - description := []string{"Item","Item2"} - if !reflect.DeepEqual(result.description, description){ - fmt.Printf("want: %q, got: %q\n", description, result.description) - t.Fatalf("want %s got %s", description, result.description) - } - }) - - t.Run("should return an error if title is badly formarted", func (t *testing.T) { - input := ` -#Title -## Skill -### Date -#### Url -Description` - _, err := Parse(input) - want := "Err: cannot parse this md line{#Title} # should be followed by space" - - if err == nil { - t.Fatalf("expected an error") - } - - if err.Error() != want { - t.Fatalf("should return a formating error, got %s should %s",err.Error(),want) - } - - }) - t.Run("should return an error if outside of allowed nb of #", func (t *testing.T) { - input := ` -# Title -## Skill -### Date -#### Url -##### oups -Description` - _, err := Parse(input) - want := "Err: cannot parse this md line{##### oups}" - - if err == nil { - t.Fatalf("expected an error") - } - if err.Error() != want { - t.Fatalf("should return a formating error, got %s should %s",err.Error(),want) - } - }) -t.Run("should return an error with mutiples section", func (t *testing.T) { - input := ` -# Title -## Skill -### Date -#### Url -Description - -# Title2 -## Skill2 -### Date2 -#### Url2 -DescriptionTwo` - got, err := Parse(input) - want := "Tried to parse mutiple paragraph into a single section" - fmt.Println(got) - - if err == nil { - t.Fatalf("expected an error") - } - if err.Error() != want { - t.Fatalf("should return a formating error, got %s should %s",err.Error(),want) - } - }) -} diff --git a/internal/markup_languages/template_sections.go b/internal/markup_languages/template_sections.go deleted file mode 100644 index fe57ae0..0000000 --- a/internal/markup_languages/template_sections.go +++ /dev/null @@ -1,34 +0,0 @@ -package markuplanguages - -const pro_item = "\\resumeItem{%ITEM%}\n" - -const prof_template = ` -\resumeSubheading - {$1}{$2} - {\href{$3}{$4}}{ } -\resumeItemListStart - %ITEMS% -\resumeItemListEnd -` -const NB_P_PROF = 4 - -const proj_template = ` -\resumeProjectHeading -{\textbf{$1} | \emph{$2 \href{$3}{\faIcon{github}}}}{} -\resumeItemListStart - %ITEMS% -\resumeItemListEnd -` -const NB_P_PROJ = 3 - -const edu_template = ` -\resumeSubheading -{\href{$2}{$1}}{} -{$3}{$4} -` -const NB_P_EDU = 4 - -const sk_template = ` -\item \textbf{$1}{: $2} \\ -` -const NB_P_SK = 2 diff --git a/internal/walker/cv.go b/internal/walker/cv.go deleted file mode 100644 index ffd93a4..0000000 --- a/internal/walker/cv.go +++ /dev/null @@ -1,40 +0,0 @@ -package walker - -import ( - "os" - "path/filepath" - "strings" -) - -// walkCV traverses the directory tree starting at root and returns a map of map where -// the keys are first the language folowed by the second key which is the section -// the values are the contents of the markdown files. -func WalkCV(root string) (map[string]map[string]string, error) { - fileMap := make(map[string]string) - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(info.Name(), ".md") { - relativePath := strings.TrimSuffix(strings.TrimPrefix(path, root+string(os.PathSeparator)), ".md") - content, err := os.ReadFile(path) - if err != nil { - return err - } - fileMap[relativePath] = string(content) - } - return nil - }) - if err != nil { - return nil, err - } - md_per_language := make(map[string]map[string]string) - for k := range fileMap{ - k_split := strings.Split(k,"/") - if md_per_language [k_split[0]] == nil { - md_per_language[k_split[0]]= make(map[string]string) - } - md_per_language[k_split[0]][k_split[1]]=fileMap[k] - } - return md_per_language, nil -} diff --git a/internal/walker/cv_test.go b/internal/walker/cv_test.go deleted file mode 100644 index 6c98297..0000000 --- a/internal/walker/cv_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package walker - -import ( - "os" - "path/filepath" - "testing" -) - -func TestWalkCV(t *testing.T) { - rootDir := t.TempDir() - paths := []struct { - relativePath string - content string - }{ - {"eng/education.md", "Education"}, - {"eng/project.md", "Project"}, - {"fr/education.md", "Education"}, - {"fr/work.md", "Work"}, - } - - for _, p := range paths { - fullPath := filepath.Join(rootDir, p.relativePath) - if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil { - t.Fatalf("Failed to create directories: %v", err) - } - if err := os.WriteFile(fullPath, []byte(p.content), os.ModePerm); err != nil { - t.Fatalf("Failed to write file: %v", err) - } - } - - result, err := WalkCV(rootDir) - if err != nil { - t.Fatalf("walkCV failed: %v", err) - } - - expected := map[string]map[string]string{ - "eng": { - "education": "Education", - "project": "Project", - }, - "fr": { - "education": "Education", - "work": "Work", - }, - } - - for lang, expectedFiles := range expected { - if resultFiles, ok := result[lang]; ok { - for file, expectedContent := range expectedFiles { - if resultContent, ok := resultFiles[file]; ok { - if resultContent != expectedContent { - t.Errorf("Expected %s/%s to be %q, got %q", lang, file, expectedContent, resultContent) - } - } else { - t.Errorf("Expected file %s/%s not found in result", lang, file) - } - } - } else { - t.Errorf("Expected language %s not found in result", lang) - } - } - - for lang := range result { - if _, found := expected[lang]; !found { - t.Errorf("Unexpected language found: %s", lang) - } - } -} diff --git a/main.go b/main.go index ddfa18b..426d190 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "anemon/cmd" + "anemon/cmd" ) func main() { diff --git a/params.yml b/params.yml new file mode 100644 index 0000000..959cf2f --- /dev/null +++ b/params.yml @@ -0,0 +1,16 @@ +info: + name: Anemon + firstname: Vincent + number: "+33 42 42 42 42 42" + mail: vincent.anemon@example.com + github: https://github.com/anemon + linkedin: https://www.linkedin.com/anemon + +variante: + webDev: + - "API" + - "backend" + Hacker: + - "Optimized" + - "Security" +