├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── discussion.yml │ ├── documentation.yml │ └── feature-request.yml └── workflows │ └── save_articles.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── Contributing.md ├── Examples.md ├── GetStarted.md ├── LICENSE ├── README.md ├── action.yml ├── dist ├── 37.index.js ├── index.js ├── types.js └── utils │ ├── createArticlesReadme.js │ ├── createMarkdownFile.js │ ├── createReadingList.js │ ├── fetchDevArticleUsingId.js │ ├── fetchDevToArticles.js │ ├── fetchDevToReadingList.js │ ├── git.js │ ├── parseMarkdownContent.js │ ├── performGitActions.js │ ├── savedArticlesReadme.js │ └── synchronizeReadingList.js ├── package.json ├── src ├── index.ts ├── types.ts └── utils │ ├── createArticlesReadme.ts │ ├── createMarkdownFile.ts │ ├── createReadingList.ts │ ├── fetchDevArticleUsingId.ts │ ├── fetchDevToArticles.ts │ ├── fetchDevToReadingList.ts │ ├── git.ts │ ├── parseMarkdownContent.ts │ ├── performGitActions.ts │ ├── savedArticlesReadme.ts │ └── synchronizeReadingList.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Anmol-Baranwal] 2 | # You can visit my GitHub sponsors profile at: https://github.com/sponsors/Anmol-Baranwal 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: ​🐞 Bug 2 | description: Report an issue to help us improve the workflow. 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A brief description of the issue. 11 | validations: 12 | required: false 13 | - type: input 14 | attributes: 15 | label: Action Version 16 | description: Write verion number in which you are facing bug. 17 | validations: 18 | required: false 19 | - type: textarea 20 | id: screenshots 21 | attributes: 22 | label: Screenshots 23 | description: Please add screenshots if applicable 24 | validations: 25 | required: false 26 | - type: dropdown 27 | id: browsers 28 | attributes: 29 | label: Platform? 30 | multiple: false 31 | options: 32 | - Ubuntu 33 | - macOS 34 | - Windows 35 | - type: checkboxes 36 | id: no-duplicate-issues 37 | attributes: 38 | label: "Checklist" 39 | description: "By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/CODE_OF_CONDUCT.md)" 40 | options: 41 | - label: "I have checked the existing [issues](https://github.com/Anmol-Baranwal/DevtoGitHub/issues?q=is%3Aissue+)" 42 | required: true 43 | - label: "I have read the [Contributing Guidelines](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/Contributing.md)" 44 | required: true 45 | - label: "I am willing to work on this issue (optional)" 46 | required: false 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion.yml: -------------------------------------------------------------------------------- 1 | name: Discussion 2 | description: Use this to ask questions and for discussion. Please do not create blank issues. 3 | title: "[Discussion]: " 4 | labels: ["discussion", "goal: new-ideas"] 5 | body: 6 | - type: textarea 7 | id: discussion 8 | attributes: 9 | label: What would you like to discuss or ask? 10 | validations: 11 | required: true 12 | - type: checkboxes 13 | id: no-duplicate-issues 14 | attributes: 15 | label: "Checklist" 16 | options: 17 | - label: "I have checked the existing [issues](https://github.com/Anmol-Baranwal/DevtoGitHub/issues?q=is%3Aissue+)" 18 | required: true 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | name: "Documentation 📋" 2 | description: "Use this form to suggest on how to improve our docs" 3 | title: "[DOCS] " 4 | labels: ["documenation"] 5 | 6 | body: 7 | - type: textarea 8 | id: docs_description 9 | attributes: 10 | label: "Issue Description" 11 | description: "Please provide a brief summary of the documentation issue you are experiencing or would like to address." 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: screenshots_examples_docs 17 | attributes: 18 | label: "Screenshots or Examples (if applicable)" 19 | description: "Please include relevant screenshots or examples to help illustrate the problem." 20 | 21 | - type: checkboxes 22 | id: terms_checklist_docs 23 | attributes: 24 | label: "Checklist" 25 | description: "By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/CODE_OF_CONDUCT.md)" 26 | options: 27 | - label: "I have checked the existing [issues](https://github.com/Anmol-Baranwal/DevtoGitHub/issues?q=is%3Aissue+)" 28 | required: true 29 | - label: "I have read the [Contributing Guidelines](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/Contributing.md)" 30 | required: true 31 | - label: "I am willing to work on this issue (optional)" 32 | required: false 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 💡 2 | description: Have any new idea or new feature to improve the workflow? Please suggest! 3 | title: "[Feature] " 4 | labels: ["enhancement", "goal: new-feature"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A clear and concise description of the feature. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: usecase 15 | attributes: 16 | label: Useful Examples 17 | description: Provide an example use case for your suggested feature. 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | id: no-duplicate-issues 22 | attributes: 23 | label: "Checklist" 24 | description: "By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/CODE_OF_CONDUCT.md)" 25 | options: 26 | - label: "I have checked the existing [issues](https://github.com/Anmol-Baranwal/DevtoGitHub/issues?q=is%3Aissue+)" 27 | required: true 28 | - label: "I have read the [Contributing Guidelines](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/Contributing.md)" 29 | required: true 30 | - label: "I am willing to work on this issue (optional)" 31 | required: false 32 | -------------------------------------------------------------------------------- /.github/workflows/save_articles.yml: -------------------------------------------------------------------------------- 1 | name: DevtoGitHub 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Run daily, adjust as needed 6 | workflow_dispatch: 7 | push: 8 | branches: ["check-2"] 9 | 10 | jobs: 11 | save_articles: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Run DevtoGitHub 19 | uses: ./ 20 | with: 21 | devApiKey: ${{ secrets.DEV_TOKEN }} 22 | saveArticles: false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | ./package-lock.json 6 | package-lock.json 7 | 8 | # Testing artifacts 9 | /coverage 10 | 11 | # Local environment files 12 | .env* 13 | !.env.example 14 | 15 | # TypeScript cache 16 | *.tsbuildinfo 17 | 18 | # VSCode directory 19 | .vscode 20 | 21 | # Yarn v2 22 | .yarn/cache 23 | .yarn/unplugged 24 | .yarn/build-state.yml 25 | .yarn/install-state.gz 26 | .pnp.* 27 | 28 | # Logs 29 | logs 30 | *.log 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | lerna-debug.log* 35 | .pnpm-debug.log* 36 | 37 | # Output of 'npm pack' 38 | *.tgz 39 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore list 2 | /* 3 | 4 | # Do not ignore these folders: 5 | !__tests__/ 6 | !__mocks__/ 7 | !.github/ 8 | !src/ -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | semi: false, 5 | singleQuote: false, 6 | trailingComma: 'none', 7 | }; -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | anmolbaranwal119@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations# Contributor Covenant Code of Conduct 133 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 4 | 5 | Please take a moment to read the following guidelines before contributing: 6 | 7 | > ⚠️IMPORTANT **Note** 8 | > 9 | > **Pull Requests _having no issue associated_ with them _will not be accepted_. Firstly get an issue assigned, whether it's already opened or raised by you, and then create a Pull Request.** 10 | 11 | ## Prerequisites ⚠️ 12 | 13 | - Open Source Etiquette: If you've never contributed to an open source project before, have a read of [Basic etiquette](https://developer.mozilla.org/en-US/docs/MDN/Community/Open_source_etiquette) for open source projects. 14 | 15 | - Basic familiarity with Git and GitHub: If you are also new to these tools, visit [GitHub for complete beginners](https://developer.mozilla.org/en-US/docs/MDN/Contribute/GitHub_beginners) for a comprehensive introduction to them. 16 | 17 | - You can read a [complete guide](https://dev.to/anmolbaranwal/a-complete-guide-to-open-source-100x-simpler-2d6c) on how the complete open source ecosystem works, including the basic flow of contribution in open source. 18 | 19 | ## Found a bug? 20 | 21 | - **Ensure the bug was not already reported** by searching the existing [Issues](https://github.com/Anmol-Baranwal/DevtoGitHub/issues?q=is%3Aissue+). 22 | - If you're unable to find an open issue addressing the problem, open a new one using this [bug template](https://github.com/Anmol-Baranwal/DevtoGitHub/issues/new?assignees=&labels=bug&projects=&template=bug.yml&title=%5BBUG%5D+%3Cconcise+description%3E). Be sure to include a **title and clear description**, and as much relevant information as possible. 23 | 24 | ## What should I know before submitting a pull request or issue 25 | 26 | > We adhere to [SemVer 2.0](https://semver.org/spec/v2.0.0.html) to the best of our ability. 27 | 28 | This workflow is written in [TypeScript](https://www.typescriptlang.org/), a typed variant of JavaScript, and we use [Prettier](https://prettier.io/) to get a consistent code style. 29 | 30 | Because of how GitHub Actions are run, the source code of this project is transpiled from TypeScript into JavaScript. The transpiled code (found in `src/`) is subsequently compiled using [NCC](https://github.com/vercel/ncc/blob/master/readme.md) (found in `dist/`) to avoid having to include the `node_modules/` directory in the repository. 31 | 32 | ## Submitting a pull request 33 | 34 | > ℹ️ Please keep your change as focused as possible. 35 | 36 | 1. Fork and clone the repository 37 | 1. Configure and install the dependencies: `npm install` 38 | 1. Create a new branch: `git checkout -b my-branch-name` 39 | 1. Make your change, test it thoroughly. You can write tests to see if all the tests are passing. 40 | 1. Update `dist/index.js` using `npm run build`. This creates a single javascript file that is used as an entrypoint for the action 41 | 1. Push to your fork and submit a pull request. 42 | 43 | ## Remarks ✅ 44 | 45 | - If something is missing here, or you feel something is not well described, please [raise an issue](https://github.com/Anmol-Baranwal/DevtoGitHub/issues/new) with relevant template. -------------------------------------------------------------------------------- /Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | > Welcome. Let's see the workflow samples along with the output it gives. 4 | > You need to create a file in the repository at the following path: `.github/workflows/dev-sync.yml` and paste the code of your choice inside. 5 | 6 | ## Saving Articles 7 | 8 | > Explanation 9 | 10 | This will save your articles when `saveArticles` is set to `true` (default). You can change which directory you want using `outputDir`. For instance, the below workflow will save your articles in the `articles` directory. The file name of each markdown file would be using the title of the article. 11 | 12 | > Workflow code 13 | 14 | ```yml 15 | name: DevtoGitHub 16 | 17 | on: 18 | schedule: 19 | - cron: "0 0 * * *" # Run daily, adjust as needed 20 | # The lines below will allow you to manually run the workflow with each commit 21 | workflow_dispatch: 22 | push: 23 | branches: ["main"] 24 | 25 | jobs: 26 | save-articles: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout Repository 30 | uses: actions/checkout@v3 31 | 32 | - name: Run DevtoGitHub 33 | uses: Anmol-Baranwal/DevtoGitHub@v1 34 | with: 35 | devApiKey: ${{ secrets.DEV_TOKEN }} 36 | saveArticles: true # default 37 | outputDir: "articles" # this is default and it will save articles in "articles" directory 38 | ``` 39 | 40 | > Output 41 | ![saving articles](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/ad357618-6fda-4f9e-b90d-39962dce9e9f) 42 | 43 | The structure of each article markdown file would be as follows: 44 | 45 | - Cover Banner Image 46 | - Article Title 47 | - Tags of the Article 48 | - Published Date 49 | - URL of the Article 50 | - Content of the Article 51 | 52 | > Output 53 | ![structure of saved article](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/24fb6087-bec8-457f-8c3f-2205a08fd873) 54 | 55 | 56 | ## Table of Contents for Saved Articles 57 | 58 | > Explanation 59 | 60 | This will save your articles when `saveArticles` is set to `true` (default). You can change which directory you want using `outputDir`. For instance, the below workflow will save your articles in the `articles` directory. It will create a table of contents with a `README` in the same directory for easy navigation of your articles within your repository. 61 | 62 | > Workflow code 63 | 64 | ```yml 65 | name: DevtoGitHub 66 | 67 | on: 68 | schedule: 69 | - cron: "0 0 * * *" # Run daily, adjust as needed 70 | # The lines below will allow you to manually run the workflow with each commit 71 | workflow_dispatch: 72 | push: 73 | branches: ["main"] 74 | 75 | jobs: 76 | save-articles: 77 | runs-on: ubuntu-latest 78 | steps: 79 | - name: Checkout Repository 80 | uses: actions/checkout@v3 81 | 82 | - name: Run DevtoGitHub 83 | uses: Anmol-Baranwal/DevtoGitHub@v1 84 | with: 85 | devApiKey: ${{ secrets.DEV_TOKEN }} 86 | saveArticles: true # default 87 | outputDir: "articles" # # this is default and it will save articles in "articles" directory 88 | saveArticlesReadme: true # this will create table of contents in "articles/README.md" 89 | ``` 90 | 91 | > Output 92 | ![saving articles with table of contents](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/6093c3ff-9ae9-43f6-b70f-96cda5dd0ce1) 93 | 94 | 95 | ## Reading List 96 | 97 | > Explanation 98 | 99 | This will stop saving your articles since `saveArticles` is set to `false` (default is `true`). It will create your reading list with the articles in a structured way. You can change which directory you want using `outputDirReading`. For instance, the below workflow will save your articles in the root directory by adding your info in a `README`. 100 | 101 | > Workflow code 102 | 103 | ```yml 104 | name: DevtoGitHub 105 | 106 | on: 107 | schedule: 108 | - cron: "0 0 * * *" # Run daily, adjust as needed 109 | # The lines below will allow you to manually run the workflow with each commit 110 | workflow_dispatch: 111 | push: 112 | branches: ["main"] 113 | 114 | jobs: 115 | save-articles: 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Checkout Repository 119 | uses: actions/checkout@v3 120 | 121 | - name: Run DevtoGitHub 122 | uses: Anmol-Baranwal/DevtoGitHub@v1 123 | with: 124 | devApiKey: ${{ secrets.DEV_TOKEN }} 125 | saveArticles: false # default is true 126 | readingList: true # default is false 127 | outputDirReading: "" # this is default and it will save reading list in Readme.md in root directory 128 | 129 | ``` 130 | 131 | > Output 132 | ![create reading list](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/d13f89a7-7999-4dab-9efb-12094918d078) 133 | 134 | 135 | ## Reading List with read time & synchronization with DEV. 136 | 137 | > Explanation 138 | 139 | This will stop saving your articles since `saveArticles` is set to `false` (default is `true`). It will create your reading list with the articles in a structured way. You can change which directory you want using `outputDirReading`. For instance, the below workflow will save your articles in the `read` directory by adding your info in a `README`. It will add a reading time with the articles when `readTime` is set to `true` (default is `false`). Setting `synchronizeReadingList` to `true` will synchronize your reading list from DEV, removing any article from your reading list on DEV will also remove it from the readme here. 140 | 141 | > Workflow code 142 | 143 | ```yml 144 | name: DevtoGitHub 145 | 146 | on: 147 | schedule: 148 | - cron: "0 0 * * *" # Run daily, adjust as needed 149 | # The lines below will allow you to manually run the workflow with each commit 150 | workflow_dispatch: 151 | push: 152 | branches: ["main"] 153 | 154 | jobs: 155 | save-articles: 156 | runs-on: ubuntu-latest 157 | steps: 158 | - name: Checkout Repository 159 | uses: actions/checkout@v3 160 | 161 | - name: Run DevtoGitHub 162 | uses: Anmol-Baranwal/DevtoGitHub@v1 163 | with: 164 | devApiKey: ${{ secrets.DEV_TOKEN }} 165 | saveArticles: false # default is true 166 | readingList: true 167 | outputDirReading: "read" # this will save reading list in read/Readme.md 168 | readTime: true 169 | synchronizeReadingList: true 170 | ``` 171 | 172 | > Output 173 | ![create a reading list with a reading time of the article](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/f5f6926d-ff73-4ad1-98d6-30833d9cf4e5) 174 | 175 | 176 | ## Reading List with excludeTags & mustIncludeTags 177 | 178 | > Explanation 179 | 180 | This will stop saving your articles since `saveArticles` is set to `false` (default is `true`). It will create your reading list with the articles in a structured way. You can change which directory you want using `outputDirReading`. For instance, the below workflow will save your articles in the root directory by adding your info in a `README`. You can read about excludeTags and mustIncludeTags in [detail with examples](https://github.com/Anmol-Baranwal/DevtoGitHub?tab=readme-ov-file#the-concept-of-excludetags-and-mustincludetags). 181 | 182 | > Workflow code 183 | 184 | ```yml 185 | name: DevtoGitHub 186 | 187 | on: 188 | schedule: 189 | - cron: "0 0 * * *" # Run daily, adjust as needed 190 | # The lines below will allow you to manually run the workflow with each commit 191 | workflow_dispatch: 192 | push: 193 | branches: ["main"] 194 | 195 | jobs: 196 | save-articles: 197 | runs-on: ubuntu-latest 198 | steps: 199 | - name: Checkout Repository 200 | uses: actions/checkout@v3 201 | 202 | - name: Run DevtoGitHub 203 | uses: Anmol-Baranwal/DevtoGitHub@v1 204 | with: 205 | devApiKey: ${{ secrets.DEV_TOKEN }} 206 | saveArticles: false # default is true 207 | readingList: true 208 | readTime: true 209 | excludeTags: `discuss` # default is empty 210 | mustIncludeTags: `programming, webdev, beginners, tutorial` # default is empty 211 | ``` 212 | 213 | ### Other options 214 | 215 | These options are not mandatory for everyone if you're not very familiar with Git & GitHub. I suggest ignoring these. Although, you're free to learn more about them if you want. 216 | 217 | > Conventional commits 218 | 219 | There are conventions for commit messages that make commits self-explanatory regarding their type. If `conventionalCommits` is set to `true` (default) then those conventions will be used. You can read more about [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 220 | 221 | > Output 222 | ![conventional commits](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/bfe6221b-cc5a-4eb7-92f8-32f33f3945e1) 223 | 224 | 225 | > Git Branch 226 | 227 | You can read more about the branch [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches). Branches will allow you to do those above process in a contained area of your repository. You can change it using `branch` whose default value is `main`. 228 | Your text is mostly grammatically correct. Here's a slightly revised version for better clarity and correctness: 229 | 230 | --- 231 | 232 | > Some examples of cron schedules (to use in workflow) 233 | 234 | In case you are looking for cron schedules, here are some common ones that you can directly use: 235 | 236 | - `0 0 * * *` - runs at midnight (0:00) every day. 237 | - `0 */12 * * *` - runs at minute 0 of every 12th hour. 238 | - `0 0 * * 0` - runs at midnight (0:00) every Sunday. 239 | - `0 0 1 * *` - runs at midnight (0:00) on the first day of every month. 240 | - `0 0 */15 * *` - runs at midnight (0:00) every 15 days. 241 | 242 |
243 | 244 | You can see the list of [input options](https://github.com/Anmol-Baranwal/DevtoGitHub?tab=readme-ov-file#inputs) that you can use with the workflow. -------------------------------------------------------------------------------- /GetStarted.md: -------------------------------------------------------------------------------- 1 | 2 | # ✅ Get Started 3 | 4 | > This is a step-by-step guide on how to set up your repository, get the API key from DEV, add it, and every other mandatory step before you can use the workflow. 5 | 6 | ## Generate DEV API key 7 | 8 | The first step is to generate a `dev.to` API token. 9 | 10 | Once you are logged into your DEV account, go to [dev.to/settings/extensions](https://dev.to/settings/extensions) 11 | 12 | In the `DEV API Keys` section, create a new key by adding a description and clicking on `Generate API Key`. 13 | 14 | ![DEV API KEY](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/014f0454-31c9-47d5-93b7-d5e80ad08a1d) 15 | 16 | You'll see the newly generated key in the same view. 17 | 18 | ![DEV API KEY](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/6f87b450-96e5-4e86-b077-0f36d12cafd5) 19 | 20 | Store this key and proceed to the next step. 21 | 22 |
23 | 24 | ## Create a Repository 25 | 26 | You can follow official documentation on [how to create a repository on GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-new-repository). I will explain, even if you don't want to. 27 | 28 | Sign in to your [GitHub](https://github.com/), select `+` in the upper right corner, and then click `new repository`. 29 | 30 | ![new repository](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/351d3bd7-64ea-4918-ba3f-f74cfe0ae5a5) 31 | 32 | Fill in details and type a name for your repository with an optional description. 33 | 34 | You can choose `private` if you don't want others to access your posts or reading list saved in the repository. Other things like `LICENSE` are optional. It's up to you if you want to include it. 35 | 36 | ![details of new repository](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/c910cb73-56ab-4843-b75e-04b990248e2c) 37 | 38 |
39 | 40 | ## Setup Repository 41 | 42 | You still need to provide `write` permissions to this token so that it can push changes to your repository. 43 | 44 | To do this, go to repository `settings > Actions > General > Workflow` permissions and select `Read and Write Permissions`. 45 | 46 | Here is a screenshot of how you can find it. 47 | 48 | ![actions for write permissions](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/e4779e30-8a23-4514-a40e-1099fe0f904c) 49 | 50 | After you have granted your token the write permissions, you are all set to proceed to the next step. 51 | 52 | To do this, go to `settings > Secrets and variables > Actions` and then click on `New Repository Secret`. 53 | 54 | ![repository secret](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/6418164e-49e6-4459-b79f-77ec2a5b982a) 55 | 56 | Now you have to put the name as `DEV_TOKEN` (used in workflow) and `Secret` as the API key you generated earlier from DEV. 57 | 58 | ![repository secret](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/9dc41a39-a8fe-44e2-859f-2d7eed0e9114) 59 | 60 | Hooray 🎉 You're all set! You can now proceed to [creating a workflow file](https://github.com/Anmol-Baranwal/DevtoGitHub/tree/main?tab=readme-ov-file#-getting-started) mentioned in the README. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anmol Baranwal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![DevtoGitHub Banner](https://github.com/Anmol-Baranwal/DevtoGitHub/assets/74038190/839a978c-cfa3-4977-a7a4-5656354642e0) 2 | 3 | # DevtoGitHub 4 | Save your DEV.to articles and reading list with a bunch of useful options. 5 | 6 | ## Use cases 7 | 8 | > The problem is that there is no way to save the articles or reading list from DEV.to as a backup, and this solves that in an efficient way. 9 | 10 | - The workflow can save your articles each in a different markdown file. 11 | - The details like tags, cover image, URL, and published time is shown in a proper format. 12 | - The best part is that you can create a table of contents in the readme to view and visit each of your articles in the saved repository. 13 | - You can also save your reading lists with specified structures and URLs for easy access. 14 | - You can display the reading time for each article in the reading list. 15 | - You can customize the directory in which you want to save the articles and the reading list. 16 | Your sentences are clear, but here are some minor grammar adjustments for clarity and correctness: 17 | - I've included [custom logic](https://github.com/Anmol-Baranwal/DevtoGitHub?tab=readme-ov-file#the-concept-of-excludetags-and-mustincludetags) based on tags to provide you with more flexibility in managing your reading list. 18 | - All articles and the reading list will be fetched regardless of the total number. 19 | - If you update an article on DEV, it will be automatically updated here the next time the workflow runs. 20 | - You can synchronize your reading list from DEV. For instance, if you remove any article from the reading list on DEV, then it will also be removed in the reading list in the readme. 21 | 22 | --- 23 | 24 | ## 🚀 Getting Started 25 | 26 | - Before you continue, you should take a few steps to create a repository and generate an API token from DEV. Don't worry, you can use this [complete guide](./GetStarted.md), which has clear instructions and image examples for each step. 27 | 28 | - Create a file in the repository at the following path: `.github/workflows/dev-sync.yml` and paste the following code into it. 29 | 30 | ```yml 31 | name: DevtoGitHub 32 | 33 | on: 34 | schedule: 35 | - cron: "0 0 * * *" # Run daily, adjust as needed 36 | # The lines below will allow you to manually run the workflow with each commit 37 | workflow_dispatch: 38 | push: 39 | branches: ["main"] 40 | 41 | jobs: 42 | save-articles: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout Repository 46 | uses: actions/checkout@v3 47 | 48 | - name: Run DevtoGitHub 49 | uses: Anmol-Baranwal/DevtoGitHub@v1 50 | with: 51 | devApiKey: ${{ secrets.DEV_TOKEN }} 52 | saveArticles: true # default 53 | outputDir: "articles" # this will save the articles in "articles" directory 54 | saveArticlesReadme: true # this will create a table of content for easy navigation 55 | ``` 56 | 57 | - For detailed instructions on custom configuration and visual samples, please refer to the [examples](./Examples.md). To get started, I've also mentioned some of the [common cron schedule](https://github.com/Anmol-Baranwal/DevtoGitHub/blob/main/Examples.md#other-options) for you to use in the workflow. 58 | 59 | --- 60 | 61 | ## Inputs 62 | 63 | Various inputs are defined to let you configure the action: 64 | 65 | | Name | Description | Default | Required | 66 | | ---- | ----------- | ------- | -------- | 67 | | `gh-token` | The GitHub token for authentication. | `'${{ github.token }}'` | `No` | 68 | | `devApiKey` | The API key from your DEV. | `''` | `Yes` | 69 | | `saveArticles` | This will save your articles in respective markdown file. | `'true'` | `No` | 70 | | `outputDir` | The directory to save your articles. Default will save it under articles directory. | `'articles'` | `No` | 71 | | `saveArticlesReadme` | To create a table of contents for your articles in readme (same directory). | `'false'` | `No` | 72 | | `readingList` | To create a reading list from DEV. | `'false'` | `No` | 73 | | `readTime` | To include the reading time for each article in the reading list. | `'false'` | `No` | 74 | | `outputDirReading` | The output directory for saving the reading list (Readme.md). Default will save it under root directory. | `''` | `No` | 75 | | `excludeTags` | To filter the reading list to avoid this tag. Use commas to separate if there are multiple tags. | `''` | `No` | 76 | | `mustIncludeTags` | To create a reading list to include this tag prioritizing over excludeTags. Use commas to separate if there are multiple tags. | `''` | `No` | 77 | | `branch` | The git branch to use for these process. | `'main'` | `No` | 78 | | `conventionalCommits` | To use conventional commit message standards. | `'true'` | `No` | 79 | | `synchronizeReadingList` | To synchronize the reading list from DEV. Removing an article from the reading list on DEV will also remove it from the repository. | `'false'` | `No` | 80 | 81 |
82 | 83 | ## The concept of excludeTags and mustIncludeTags 84 | 85 | The Combinations that you can use with `readingList`: 86 | 87 | As you're aware, there are four tags (max) for each article. 88 | So, I devised a way to give you some flexibility based on the tags. 89 | 90 | Suppose you want to remove some articles with tag `#discuss` but want to include the post if that article with `#discuss` tag also has a `#programming` tag. So, you can include `#discuss` in `exlcudeTags` & `#programming` in `mustIncludeTags`. 91 | In case you feel confused. Let's understand it with an example. 92 | 93 | Suppose we have an article with tags: `['react', 'javascript', 'frontend', 'tutorial']`. 94 | 95 | - If `excludeTags` is 'frontend' and `mustIncludeTags` is 'javascript'. The article is included because it has the `javascript` tag (even though it also has the `frontend` tag). 96 | - If `excludeTags` is 'tutorial' and `mustIncludeTags` is empty (default), the article will be excluded because it has the `tutorial` tag. 97 | - If `excludeTags` is 'backend' and `mustIncludeTags` is 'typescript'. The article is included because it does not have the `backend` tag. 98 | - These cases will work for multiple tags, and `mustIncludeTags` will only work if `excludeTags` is provided. 99 | 100 | --- 101 | 102 | ## 🤝 How to Contribute? 103 | 104 | All changes are welcome. Please read our [contributing guidelines](Contributing.md) 105 | 106 | Feel free to suggest any features or report bugs using these [issue templates](https://github.com/Anmol-Baranwal/DevtoGitHub/issues/new/choose). 107 | 108 | --- 109 | 110 | ## 📝 License 111 | 112 | 113 | 114 | 117 | 121 | 122 |
115 |

116 |

118 |
119 | The scripts and documentation in this project are released under the MIT License. 120 |
123 | 124 | --- 125 | 126 | ## bullseye Tech & Tools 127 | 128 | > In case you want to run the action locally, without having to commit/push every time, you can use the [act](https://github.com/nektos/act) tool. 129 | 130 | 131 | 132 | 133 | - I've used Forem v1 APIs for building DevtoGithub. You can refer to the [docs](https://developers.forem.com/api/v1). 134 | 135 | --- 136 | 137 | ## Author 138 | 139 | > Feel free to contact me if you need a custom workflow for your project. I'll be happy to build one. 140 | 141 | 142 | 143 |
GitHub Profile of Anmol Baranwal
Anmol Baranwal

@Anmol-Baranwal
144 | 145 | I would appreciate if you could give this repository a star 🌟. It would help others to discover this. 146 | Thank you for your support 💜 147 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'DevtoGitHub' 2 | description: 'Save your Dev.to articles with all the necessary information on GitHub' 3 | author: 'Anmol Baranwal ' 4 | inputs: 5 | devApiKey: 6 | description: 'Dev API key (required)' 7 | required: true 8 | gh-token: 9 | description: 'The GitHub token for authentication.' 10 | default: ${{ github.token }} 11 | required: false 12 | outputDir: 13 | description: 'The output directory for creating markdown files' 14 | default: 'articles' 15 | required: false 16 | outputDirReading: 17 | description: 'The output directory for saving reading list' 18 | default: '' 19 | required: false 20 | readingList: 21 | description: 'To create a reading list from DEV.' 22 | default: 'false' 23 | required: false 24 | readTime: 25 | description: 'Reading time for articles in reading list' 26 | default: 'false' 27 | required: false 28 | excludeTags: 29 | description: 'To filter the reading list to avoid from this tag.' 30 | default: '' 31 | required: false 32 | mustIncludeTags: 33 | description: 'To create a reading list from DEV to include this tag at all costs.' 34 | default: '' 35 | required: false 36 | synchronizeReadingList: 37 | description: 'To synchronize reading list with dev.to' 38 | default: 'false' 39 | required: false 40 | saveArticles: 41 | description: 'To save all the articles with all the necessary information.' 42 | default: 'true' 43 | required: false 44 | saveArticlesReadme: 45 | description: 'To create a list of saved articles linked with their respective file in the readme.' 46 | default: 'false' 47 | required: false 48 | branch: 49 | description: 'The git branch to use' 50 | required: false 51 | default: 'main' 52 | conventionalCommits: 53 | description: 'Use conventional commit message standards' 54 | required: false 55 | default: 'true' 56 | runs: 57 | using: 'node20' 58 | main: 'dist/index.js' 59 | branding: 60 | icon: 'save' 61 | color: 'gray-dark' 62 | -------------------------------------------------------------------------------- /dist/37.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 37; 3 | exports.ids = [37]; 4 | exports.modules = { 5 | 6 | /***/ 4037: 7 | /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { 8 | 9 | __webpack_require__.r(__webpack_exports__); 10 | /* harmony export */ __webpack_require__.d(__webpack_exports__, { 11 | /* harmony export */ "toFormData": () => (/* binding */ toFormData) 12 | /* harmony export */ }); 13 | /* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2777); 14 | /* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8010); 15 | 16 | 17 | 18 | let s = 0; 19 | const S = { 20 | START_BOUNDARY: s++, 21 | HEADER_FIELD_START: s++, 22 | HEADER_FIELD: s++, 23 | HEADER_VALUE_START: s++, 24 | HEADER_VALUE: s++, 25 | HEADER_VALUE_ALMOST_DONE: s++, 26 | HEADERS_ALMOST_DONE: s++, 27 | PART_DATA_START: s++, 28 | PART_DATA: s++, 29 | END: s++ 30 | }; 31 | 32 | let f = 1; 33 | const F = { 34 | PART_BOUNDARY: f, 35 | LAST_BOUNDARY: f *= 2 36 | }; 37 | 38 | const LF = 10; 39 | const CR = 13; 40 | const SPACE = 32; 41 | const HYPHEN = 45; 42 | const COLON = 58; 43 | const A = 97; 44 | const Z = 122; 45 | 46 | const lower = c => c | 0x20; 47 | 48 | const noop = () => {}; 49 | 50 | class MultipartParser { 51 | /** 52 | * @param {string} boundary 53 | */ 54 | constructor(boundary) { 55 | this.index = 0; 56 | this.flags = 0; 57 | 58 | this.onHeaderEnd = noop; 59 | this.onHeaderField = noop; 60 | this.onHeadersEnd = noop; 61 | this.onHeaderValue = noop; 62 | this.onPartBegin = noop; 63 | this.onPartData = noop; 64 | this.onPartEnd = noop; 65 | 66 | this.boundaryChars = {}; 67 | 68 | boundary = '\r\n--' + boundary; 69 | const ui8a = new Uint8Array(boundary.length); 70 | for (let i = 0; i < boundary.length; i++) { 71 | ui8a[i] = boundary.charCodeAt(i); 72 | this.boundaryChars[ui8a[i]] = true; 73 | } 74 | 75 | this.boundary = ui8a; 76 | this.lookbehind = new Uint8Array(this.boundary.length + 8); 77 | this.state = S.START_BOUNDARY; 78 | } 79 | 80 | /** 81 | * @param {Uint8Array} data 82 | */ 83 | write(data) { 84 | let i = 0; 85 | const length_ = data.length; 86 | let previousIndex = this.index; 87 | let {lookbehind, boundary, boundaryChars, index, state, flags} = this; 88 | const boundaryLength = this.boundary.length; 89 | const boundaryEnd = boundaryLength - 1; 90 | const bufferLength = data.length; 91 | let c; 92 | let cl; 93 | 94 | const mark = name => { 95 | this[name + 'Mark'] = i; 96 | }; 97 | 98 | const clear = name => { 99 | delete this[name + 'Mark']; 100 | }; 101 | 102 | const callback = (callbackSymbol, start, end, ui8a) => { 103 | if (start === undefined || start !== end) { 104 | this[callbackSymbol](ui8a && ui8a.subarray(start, end)); 105 | } 106 | }; 107 | 108 | const dataCallback = (name, clear) => { 109 | const markSymbol = name + 'Mark'; 110 | if (!(markSymbol in this)) { 111 | return; 112 | } 113 | 114 | if (clear) { 115 | callback(name, this[markSymbol], i, data); 116 | delete this[markSymbol]; 117 | } else { 118 | callback(name, this[markSymbol], data.length, data); 119 | this[markSymbol] = 0; 120 | } 121 | }; 122 | 123 | for (i = 0; i < length_; i++) { 124 | c = data[i]; 125 | 126 | switch (state) { 127 | case S.START_BOUNDARY: 128 | if (index === boundary.length - 2) { 129 | if (c === HYPHEN) { 130 | flags |= F.LAST_BOUNDARY; 131 | } else if (c !== CR) { 132 | return; 133 | } 134 | 135 | index++; 136 | break; 137 | } else if (index - 1 === boundary.length - 2) { 138 | if (flags & F.LAST_BOUNDARY && c === HYPHEN) { 139 | state = S.END; 140 | flags = 0; 141 | } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { 142 | index = 0; 143 | callback('onPartBegin'); 144 | state = S.HEADER_FIELD_START; 145 | } else { 146 | return; 147 | } 148 | 149 | break; 150 | } 151 | 152 | if (c !== boundary[index + 2]) { 153 | index = -2; 154 | } 155 | 156 | if (c === boundary[index + 2]) { 157 | index++; 158 | } 159 | 160 | break; 161 | case S.HEADER_FIELD_START: 162 | state = S.HEADER_FIELD; 163 | mark('onHeaderField'); 164 | index = 0; 165 | // falls through 166 | case S.HEADER_FIELD: 167 | if (c === CR) { 168 | clear('onHeaderField'); 169 | state = S.HEADERS_ALMOST_DONE; 170 | break; 171 | } 172 | 173 | index++; 174 | if (c === HYPHEN) { 175 | break; 176 | } 177 | 178 | if (c === COLON) { 179 | if (index === 1) { 180 | // empty header field 181 | return; 182 | } 183 | 184 | dataCallback('onHeaderField', true); 185 | state = S.HEADER_VALUE_START; 186 | break; 187 | } 188 | 189 | cl = lower(c); 190 | if (cl < A || cl > Z) { 191 | return; 192 | } 193 | 194 | break; 195 | case S.HEADER_VALUE_START: 196 | if (c === SPACE) { 197 | break; 198 | } 199 | 200 | mark('onHeaderValue'); 201 | state = S.HEADER_VALUE; 202 | // falls through 203 | case S.HEADER_VALUE: 204 | if (c === CR) { 205 | dataCallback('onHeaderValue', true); 206 | callback('onHeaderEnd'); 207 | state = S.HEADER_VALUE_ALMOST_DONE; 208 | } 209 | 210 | break; 211 | case S.HEADER_VALUE_ALMOST_DONE: 212 | if (c !== LF) { 213 | return; 214 | } 215 | 216 | state = S.HEADER_FIELD_START; 217 | break; 218 | case S.HEADERS_ALMOST_DONE: 219 | if (c !== LF) { 220 | return; 221 | } 222 | 223 | callback('onHeadersEnd'); 224 | state = S.PART_DATA_START; 225 | break; 226 | case S.PART_DATA_START: 227 | state = S.PART_DATA; 228 | mark('onPartData'); 229 | // falls through 230 | case S.PART_DATA: 231 | previousIndex = index; 232 | 233 | if (index === 0) { 234 | // boyer-moore derrived algorithm to safely skip non-boundary data 235 | i += boundaryEnd; 236 | while (i < bufferLength && !(data[i] in boundaryChars)) { 237 | i += boundaryLength; 238 | } 239 | 240 | i -= boundaryEnd; 241 | c = data[i]; 242 | } 243 | 244 | if (index < boundary.length) { 245 | if (boundary[index] === c) { 246 | if (index === 0) { 247 | dataCallback('onPartData', true); 248 | } 249 | 250 | index++; 251 | } else { 252 | index = 0; 253 | } 254 | } else if (index === boundary.length) { 255 | index++; 256 | if (c === CR) { 257 | // CR = part boundary 258 | flags |= F.PART_BOUNDARY; 259 | } else if (c === HYPHEN) { 260 | // HYPHEN = end boundary 261 | flags |= F.LAST_BOUNDARY; 262 | } else { 263 | index = 0; 264 | } 265 | } else if (index - 1 === boundary.length) { 266 | if (flags & F.PART_BOUNDARY) { 267 | index = 0; 268 | if (c === LF) { 269 | // unset the PART_BOUNDARY flag 270 | flags &= ~F.PART_BOUNDARY; 271 | callback('onPartEnd'); 272 | callback('onPartBegin'); 273 | state = S.HEADER_FIELD_START; 274 | break; 275 | } 276 | } else if (flags & F.LAST_BOUNDARY) { 277 | if (c === HYPHEN) { 278 | callback('onPartEnd'); 279 | state = S.END; 280 | flags = 0; 281 | } else { 282 | index = 0; 283 | } 284 | } else { 285 | index = 0; 286 | } 287 | } 288 | 289 | if (index > 0) { 290 | // when matching a possible boundary, keep a lookbehind reference 291 | // in case it turns out to be a false lead 292 | lookbehind[index - 1] = c; 293 | } else if (previousIndex > 0) { 294 | // if our boundary turned out to be rubbish, the captured lookbehind 295 | // belongs to partData 296 | const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength); 297 | callback('onPartData', 0, previousIndex, _lookbehind); 298 | previousIndex = 0; 299 | mark('onPartData'); 300 | 301 | // reconsider the current character even so it interrupted the sequence 302 | // it could be the beginning of a new sequence 303 | i--; 304 | } 305 | 306 | break; 307 | case S.END: 308 | break; 309 | default: 310 | throw new Error(`Unexpected state entered: ${state}`); 311 | } 312 | } 313 | 314 | dataCallback('onHeaderField'); 315 | dataCallback('onHeaderValue'); 316 | dataCallback('onPartData'); 317 | 318 | // Update properties for the next call 319 | this.index = index; 320 | this.state = state; 321 | this.flags = flags; 322 | } 323 | 324 | end() { 325 | if ((this.state === S.HEADER_FIELD_START && this.index === 0) || 326 | (this.state === S.PART_DATA && this.index === this.boundary.length)) { 327 | this.onPartEnd(); 328 | } else if (this.state !== S.END) { 329 | throw new Error('MultipartParser.end(): stream ended unexpectedly'); 330 | } 331 | } 332 | } 333 | 334 | function _fileName(headerValue) { 335 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 336 | const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i); 337 | if (!m) { 338 | return; 339 | } 340 | 341 | const match = m[2] || m[3] || ''; 342 | let filename = match.slice(match.lastIndexOf('\\') + 1); 343 | filename = filename.replace(/%22/g, '"'); 344 | filename = filename.replace(/&#(\d{4});/g, (m, code) => { 345 | return String.fromCharCode(code); 346 | }); 347 | return filename; 348 | } 349 | 350 | async function toFormData(Body, ct) { 351 | if (!/multipart/i.test(ct)) { 352 | throw new TypeError('Failed to fetch'); 353 | } 354 | 355 | const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); 356 | 357 | if (!m) { 358 | throw new TypeError('no or bad content-type header, no multipart boundary'); 359 | } 360 | 361 | const parser = new MultipartParser(m[1] || m[2]); 362 | 363 | let headerField; 364 | let headerValue; 365 | let entryValue; 366 | let entryName; 367 | let contentType; 368 | let filename; 369 | const entryChunks = []; 370 | const formData = new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__/* .FormData */ .Ct(); 371 | 372 | const onPartData = ui8a => { 373 | entryValue += decoder.decode(ui8a, {stream: true}); 374 | }; 375 | 376 | const appendToFile = ui8a => { 377 | entryChunks.push(ui8a); 378 | }; 379 | 380 | const appendFileToFormData = () => { 381 | const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__/* .File */ .$B(entryChunks, filename, {type: contentType}); 382 | formData.append(entryName, file); 383 | }; 384 | 385 | const appendEntryToFormData = () => { 386 | formData.append(entryName, entryValue); 387 | }; 388 | 389 | const decoder = new TextDecoder('utf-8'); 390 | decoder.decode(); 391 | 392 | parser.onPartBegin = function () { 393 | parser.onPartData = onPartData; 394 | parser.onPartEnd = appendEntryToFormData; 395 | 396 | headerField = ''; 397 | headerValue = ''; 398 | entryValue = ''; 399 | entryName = ''; 400 | contentType = ''; 401 | filename = null; 402 | entryChunks.length = 0; 403 | }; 404 | 405 | parser.onHeaderField = function (ui8a) { 406 | headerField += decoder.decode(ui8a, {stream: true}); 407 | }; 408 | 409 | parser.onHeaderValue = function (ui8a) { 410 | headerValue += decoder.decode(ui8a, {stream: true}); 411 | }; 412 | 413 | parser.onHeaderEnd = function () { 414 | headerValue += decoder.decode(); 415 | headerField = headerField.toLowerCase(); 416 | 417 | if (headerField === 'content-disposition') { 418 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 419 | const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i); 420 | 421 | if (m) { 422 | entryName = m[2] || m[3] || ''; 423 | } 424 | 425 | filename = _fileName(headerValue); 426 | 427 | if (filename) { 428 | parser.onPartData = appendToFile; 429 | parser.onPartEnd = appendFileToFormData; 430 | } 431 | } else if (headerField === 'content-type') { 432 | contentType = headerValue; 433 | } 434 | 435 | headerValue = ''; 436 | headerField = ''; 437 | }; 438 | 439 | for await (const chunk of Body) { 440 | parser.write(chunk); 441 | } 442 | 443 | parser.end(); 444 | 445 | return formData; 446 | } 447 | 448 | 449 | /***/ }) 450 | 451 | }; 452 | ; -------------------------------------------------------------------------------- /dist/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | -------------------------------------------------------------------------------- /dist/utils/createArticlesReadme.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.createArticlesReadme = void 0; 27 | const core = __importStar(require("@actions/core")); 28 | const fs = __importStar(require("fs")); 29 | const git_1 = require("./git"); 30 | const performGitActions_1 = require("./performGitActions"); 31 | const conventionalCommits = core.getInput("conventionalCommits") === "true" || true; 32 | async function createArticlesReadme(articles, outputDir, branch) { 33 | // Create content for README.md 34 | let readmeContent = ""; 35 | const readmePath = `${outputDir}/README.md`; 36 | if (fs.existsSync(readmePath)) { 37 | readmeContent = fs.readFileSync(readmePath, "utf8"); 38 | } 39 | const hasTableOfContentsHeading = readmeContent.includes("# Table of Contents\n\n"); 40 | // Set the commit message based on whether the heading exists 41 | let commitMessage = hasTableOfContentsHeading 42 | ? "update readme with table of contents" 43 | : "create readme with table of contents"; 44 | if (!hasTableOfContentsHeading) { 45 | readmeContent = "# Table of Contents\n\n"; 46 | } 47 | for (const article of articles) { 48 | const fileName = (0, git_1.getFileNameFromTitle)(article.title).trim(); 49 | const fileLink = `./${fileName}.md`; 50 | if (readmeContent.includes(`[${article.title}]`)) { 51 | console.log(`Skipping "${article.title}" because it already exists in the table of contents.`); 52 | continue; 53 | } 54 | // Add entry to README content 55 | readmeContent += `- [${article.title}](${fileLink.replace(/ /g, "%20")})\n`; 56 | } 57 | // Write README.md 58 | fs.writeFileSync(readmePath, readmeContent); 59 | if (conventionalCommits) { 60 | commitMessage = `chore: ${commitMessage.toLowerCase()}`; 61 | } 62 | (0, performGitActions_1.performGitActions)({ 63 | commitMessage, 64 | path: readmePath, 65 | branch, 66 | noticeMessage: "README.md file created and committed" 67 | }); 68 | } 69 | exports.createArticlesReadme = createArticlesReadme; 70 | -------------------------------------------------------------------------------- /dist/utils/createMarkdownFile.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.createMarkdownFile = void 0; 27 | const core = __importStar(require("@actions/core")); 28 | const fs = __importStar(require("fs")); 29 | const git_1 = require("./git"); 30 | const parseMarkdownContent_1 = require("./parseMarkdownContent"); 31 | const fetchDevArticleUsingId_1 = require("./fetchDevArticleUsingId"); 32 | const performGitActions_1 = require("./performGitActions"); 33 | const createArticlesReadme_1 = require("./createArticlesReadme"); 34 | const conventionalCommits = core.getInput("conventionalCommits") === "true" || true; 35 | async function createMarkdownFile(articles, outputDir, branch, apiKey) { 36 | // output directory must exist 37 | if (!fs.existsSync(outputDir)) { 38 | try { 39 | // Create the directory with necessary permissions 40 | fs.mkdirSync(outputDir); 41 | } 42 | catch (error) { 43 | core.setFailed(`Failed to create directory ${outputDir}: ${error.message}`); 44 | return; 45 | } 46 | } 47 | for (const article of articles) { 48 | const fileName = (0, git_1.getFileNameFromTitle)(article.title).trim(); 49 | const filePath = `./${outputDir}/${fileName}.md`; 50 | let commitMessage; 51 | // Check if the markdown file already exists 52 | if (!fs.existsSync(filePath)) { 53 | commitMessage = `add ${fileName} markdown file`; 54 | if (conventionalCommits) { 55 | commitMessage = `chore: ${commitMessage.toLowerCase()}`; 56 | } 57 | const markdownContent = (0, parseMarkdownContent_1.parseMarkdownContent)(article); 58 | // Write markdown content to file 59 | fs.writeFileSync(filePath, markdownContent); 60 | (0, performGitActions_1.performGitActions)({ 61 | commitMessage, 62 | path: filePath, 63 | branch 64 | // noticeMessage: "Markdown file created and committed" 65 | }); 66 | // core.notice(`Markdown file created: ${filePath}`) 67 | } 68 | else { 69 | const existingContent = fs.readFileSync(filePath, "utf8"); 70 | const fetchedArticle = await (0, fetchDevArticleUsingId_1.fetchDevArticleUsingId)(article.id, apiKey); 71 | // Check if the article has been edited by comparing the existing content with the fetched article's content 72 | const newMarkdownContent = (0, parseMarkdownContent_1.parseMarkdownContent)(fetchedArticle, { 73 | option: "2" 74 | }); 75 | if (existingContent !== newMarkdownContent) { 76 | core.notice(`Article has been edited, updating the Markdown file content.`); 77 | fs.writeFileSync(filePath, newMarkdownContent); 78 | commitMessage = `update ${fileName} markdown file with edited content`; 79 | if (conventionalCommits) { 80 | commitMessage = `chore: ${commitMessage.toLowerCase()}`; 81 | } 82 | (0, performGitActions_1.performGitActions)({ 83 | commitMessage, 84 | path: filePath, 85 | branch, 86 | noticeMessage: "Markdown file created and committed" 87 | }); 88 | } 89 | else { 90 | core.notice(`Markdown file already exists for "${article.title}" and it is not edited. Skipping.`); 91 | } 92 | } 93 | } 94 | const tableOfContents = core.getInput("saveArticlesReadme") === "true" || false; 95 | if (tableOfContents) { 96 | await (0, createArticlesReadme_1.createArticlesReadme)(articles, outputDir, branch); 97 | } 98 | } 99 | exports.createMarkdownFile = createMarkdownFile; 100 | -------------------------------------------------------------------------------- /dist/utils/createReadingList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.createReadingList = void 0; 27 | const core = __importStar(require("@actions/core")); 28 | const fs = __importStar(require("fs")); 29 | const performGitActions_1 = require("./performGitActions"); 30 | async function createReadingList(articles, outputDir, branch) { 31 | const readTime = core.getInput("readTime") === "true" || false; 32 | const conventionalCommits = core.getInput("conventionalCommits") === "true" || true; 33 | // Ensure the output directory exists 34 | if (!fs.existsSync(outputDir)) { 35 | try { 36 | fs.mkdirSync(outputDir); 37 | } 38 | catch (error) { 39 | core.setFailed(`Failed to create directory ${outputDir}: ${error.message}`); 40 | return; 41 | } 42 | } 43 | let existingContent = ""; 44 | const readmePath = `./${outputDir}/README.md`; 45 | if (fs.existsSync(readmePath)) { 46 | existingContent = fs.readFileSync(readmePath, "utf8"); 47 | } 48 | else { 49 | fs.writeFileSync(readmePath, ""); 50 | existingContent = fs.readFileSync(readmePath, "utf8"); 51 | } 52 | const hasReadingListHeading = existingContent.includes("## Reading List"); 53 | let commitMessage = hasReadingListHeading 54 | ? "update reading list" 55 | : "create reading list"; 56 | if (conventionalCommits) { 57 | commitMessage = `chore: ${commitMessage.toLowerCase()}`; 58 | } 59 | // Check if the reading list heading exists, if not add it 60 | if (!hasReadingListHeading) { 61 | existingContent += "\n
\n\n## Reading List\n\n"; 62 | } 63 | // Add bullet points for each article 64 | for (const articleItem of articles) { 65 | const articleUrl = articleItem.article.url; 66 | // url is used to avoid adding duplicate articles 67 | if (existingContent.includes(articleUrl)) { 68 | console.log(`Skipping article "${articleItem.article.title}" because it already exists in the reading list.`); 69 | continue; 70 | } 71 | if (readTime) { 72 | existingContent += `- [${articleItem.article.title}](${articleItem.article.url}) - ${articleItem.article.reading_time_minutes} minutes\n`; 73 | } 74 | else { 75 | existingContent += `- [${articleItem.article.title}](${articleItem.article.url})\n`; 76 | } 77 | } 78 | fs.writeFileSync(readmePath, existingContent); 79 | (0, performGitActions_1.performGitActions)({ 80 | commitMessage, 81 | path: readmePath, 82 | branch, 83 | noticeMessage: "Reading list file created and committed" 84 | }); 85 | core.notice(`Reading list updated in README.md`); 86 | } 87 | exports.createReadingList = createReadingList; 88 | -------------------------------------------------------------------------------- /dist/utils/fetchDevArticleUsingId.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.fetchDevArticleUsingId = void 0; 4 | async function fetchDevArticleUsingId(id, apiKey) { 5 | const apiUrl = `https://dev.to/api/articles/${id}`; 6 | const headers = { 7 | "Content-Type": "application/json", 8 | "api-key": apiKey 9 | }; 10 | const response = await fetch(apiUrl, { headers }); 11 | if (!response.ok) { 12 | throw new Error(`Failed to fetch article. Status: ${response.status}`); 13 | } 14 | const article = await response.json(); 15 | return article; 16 | } 17 | exports.fetchDevArticleUsingId = fetchDevArticleUsingId; 18 | -------------------------------------------------------------------------------- /dist/utils/fetchDevToArticles.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.fetchDevToArticles = void 0; 7 | const node_fetch_1 = __importDefault(require("node-fetch")); 8 | async function fetchDevToArticles(apiKey, per_page) { 9 | if (per_page === undefined) 10 | per_page = 999; // default is 30 11 | let page = 1; 12 | let articles = []; 13 | while (true) { 14 | const apiUrl = `https://dev.to/api/articles/me?page=${page}&per_page=${per_page}`; 15 | const headers = { 16 | "Content-Type": "application/json", 17 | "api-key": apiKey 18 | }; 19 | const response = await (0, node_fetch_1.default)(apiUrl, { headers }); 20 | if (!response.ok) { 21 | throw new Error(`Failed to fetch articles. Status: ${response.status}`); 22 | } 23 | const pageArticles = (await response.json()); 24 | if (pageArticles.length === 0) { 25 | break; // No more articles left 26 | } 27 | articles = articles.concat(pageArticles); 28 | page++; 29 | } 30 | return articles; 31 | } 32 | exports.fetchDevToArticles = fetchDevToArticles; 33 | -------------------------------------------------------------------------------- /dist/utils/fetchDevToReadingList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | var __importDefault = (this && this.__importDefault) || function (mod) { 26 | return (mod && mod.__esModule) ? mod : { "default": mod }; 27 | }; 28 | Object.defineProperty(exports, "__esModule", { value: true }); 29 | exports.fetchDevToReadingList = void 0; 30 | const node_fetch_1 = __importDefault(require("node-fetch")); 31 | const core = __importStar(require("@actions/core")); 32 | const filteredArticles = (articles, excludeTags, mustIncludeTags) => { 33 | if (excludeTags.length === 0) { 34 | // No filtering if excludeTags is empty 35 | return articles; 36 | } 37 | return articles.filter((articleItem) => { 38 | const articleTags = articleItem.article.tags; 39 | const hasExcludedTag = excludeTags.some((tag) => articleTags.includes(tag)); 40 | const hasMustIncludeTag = mustIncludeTags.length !== 0 && 41 | mustIncludeTags.some((tag) => articleTags.includes(tag)); 42 | const shouldInclude = hasMustIncludeTag || !hasExcludedTag; 43 | return shouldInclude; 44 | }); 45 | }; 46 | async function fetchDevToReadingList(apiKey, per_page) { 47 | if (per_page === undefined) 48 | per_page = 30; // Default per page value is 30 49 | let page = 1; 50 | let readingList = []; 51 | while (true) { 52 | const apiUrl = `https://dev.to/api/readinglist?page=${page}&per_page=${per_page}`; 53 | const headers = { 54 | "Content-Type": "application/json", 55 | "api-key": apiKey 56 | }; 57 | const response = await (0, node_fetch_1.default)(apiUrl, { headers }); 58 | if (!response.ok) { 59 | throw new Error(`Failed to fetch reading list. Status: ${response.status}`); 60 | } 61 | const articles = (await response.json()); 62 | if (articles.length === 0) { 63 | break; // break when no more articles left 64 | } 65 | readingList = readingList.concat(articles); 66 | page++; 67 | } 68 | core.notice("Reading list fetched successfully."); 69 | const excludeTags = core 70 | .getInput("excludeTags") 71 | .split(",") 72 | .map((tag) => tag.trim()); 73 | const mustIncludeTags = core 74 | .getInput("mustIncludeTags") 75 | .split(",") 76 | .map((tag) => tag.trim()); 77 | const filteredReadingList = filteredArticles(readingList, excludeTags, mustIncludeTags); 78 | return filteredReadingList; 79 | } 80 | exports.fetchDevToReadingList = fetchDevToReadingList; 81 | -------------------------------------------------------------------------------- /dist/utils/git.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.gitPull = exports.gitConfig = exports.gitPush = exports.gitCommit = exports.gitAdd = exports.getFileNameFromTitle = void 0; 27 | const exec = __importStar(require("@actions/exec")); 28 | const core = __importStar(require("@actions/core")); 29 | // generate a valid file name using the title 30 | function getFileNameFromTitle(title) { 31 | // Replace special characters other than apostrophes and hyphens with spaces 32 | return title 33 | .replace(/[^\w\s'-]/gi, " ") 34 | .replace(/\s+/g, " ") 35 | .toLowerCase(); 36 | } 37 | exports.getFileNameFromTitle = getFileNameFromTitle; 38 | async function gitAdd(filePath) { 39 | try { 40 | await exec.exec("git", ["add", filePath]); 41 | } 42 | catch (error) { 43 | core.notice(`Failed to complete git add: ${error.message}`); 44 | } 45 | } 46 | exports.gitAdd = gitAdd; 47 | async function gitCommit(message, filePath) { 48 | try { 49 | const statusOutput = await exec.getExecOutput("git", [ 50 | "status", 51 | "--porcelain", 52 | filePath 53 | ]); 54 | if (statusOutput.stdout.trim() === "") { 55 | core.notice(`No changes to commit for file ${filePath}`); 56 | return; 57 | } 58 | await exec.exec("git", ["commit", "-m", message, filePath]); 59 | } 60 | catch (error) { 61 | core.notice(`Failed to complete git commit: ${error.message}`); 62 | } 63 | } 64 | exports.gitCommit = gitCommit; 65 | async function gitPush(branch) { 66 | try { 67 | await exec.exec("git", ["push", "origin", `HEAD:${branch}`]); 68 | } 69 | catch (error) { 70 | core.notice(`Failed to complete git push: ${error.message}`); 71 | } 72 | } 73 | exports.gitPush = gitPush; 74 | async function gitConfig() { 75 | try { 76 | await exec.exec("git", [ 77 | "config", 78 | "--global", 79 | "user.email", 80 | "actions@github.com" 81 | ]); 82 | await exec.exec("git", [ 83 | "config", 84 | "--global", 85 | "user.name", 86 | "GitHub Actions" 87 | ]); 88 | } 89 | catch (error) { 90 | core.notice(`Failed to set up Git configuration: ${error.message}`); 91 | } 92 | } 93 | exports.gitConfig = gitConfig; 94 | async function gitPull(branch) { 95 | try { 96 | await exec.exec("git", ["pull", "origin", branch]); 97 | } 98 | catch (error) { 99 | core.notice(`Failed to pull changes from ${branch}: ${error.message}`); 100 | } 101 | } 102 | exports.gitPull = gitPull; 103 | -------------------------------------------------------------------------------- /dist/utils/parseMarkdownContent.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.parseMarkdownContent = void 0; 4 | function parseMarkdownContent(article, choice = { option: "1" }) { 5 | const coverImageBanner = article.cover_image 6 | ? `Cover Image` 7 | : ""; 8 | const formattedTimestamp = formatTimestamp(article.published_timestamp); 9 | // the api response of article fetched using id has different fields compared to api response of user's article. 10 | const formattedTags = choice && choice.option === "2" 11 | ? article.tags.join(", ") 12 | : article.tag_list.map((tag) => `\`${tag}\``).join(", "); 13 | return `\ 14 | ${coverImageBanner} 15 |
16 | 17 | # ${article.title} 18 | 19 | **Tags:** ${formattedTags} 20 | 21 | **Published At:** ${formattedTimestamp} 22 | 23 | **URL:** [${article.url}](${article.url}) 24 | 25 |
26 | ${article.body_markdown} 27 | `; 28 | } 29 | exports.parseMarkdownContent = parseMarkdownContent; 30 | const formatTimestamp = (timestamp) => { 31 | const date = new Date(timestamp); 32 | return date.toLocaleString(); 33 | }; 34 | -------------------------------------------------------------------------------- /dist/utils/performGitActions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.performGitActions = void 0; 27 | const core = __importStar(require("@actions/core")); 28 | const git_1 = require("./git"); 29 | async function performGitActions({ commitMessage, path, branch, noticeMessage }) { 30 | try { 31 | await (0, git_1.gitConfig)(); 32 | await (0, git_1.gitAdd)(path); 33 | await (0, git_1.gitCommit)(commitMessage, path); 34 | await (0, git_1.gitPull)(branch); 35 | await (0, git_1.gitPush)(branch); 36 | if (noticeMessage) 37 | core.notice(noticeMessage); 38 | } 39 | catch (error) { 40 | core.notice(`Failed to commit and push changes: ${error.message}`); 41 | } 42 | } 43 | exports.performGitActions = performGitActions; 44 | -------------------------------------------------------------------------------- /dist/utils/savedArticlesReadme.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | -------------------------------------------------------------------------------- /dist/utils/synchronizeReadingList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.synchronizeReadingList = void 0; 27 | const fs = __importStar(require("fs")); 28 | const core = __importStar(require("@actions/core")); 29 | const performGitActions_1 = require("./performGitActions"); 30 | async function synchronizeReadingList(readingList, outputDir, branch) { 31 | const readmePath = `./${outputDir}/README.md`; 32 | let commitMessage = "synchronize reading list"; 33 | const conventionalCommits = core.getInput("conventionalCommits") === "true" || true; 34 | if (conventionalCommits) { 35 | commitMessage = `chore: ${commitMessage.toLowerCase()}`; 36 | } 37 | try { 38 | const existingContent = fs.readFileSync(readmePath, "utf8"); 39 | // For logging names of removed articles 40 | const removedArticles = []; 41 | // Iterate each line in the readme 42 | let updatedContent = existingContent 43 | .split("\n") 44 | .filter((line) => { 45 | // Extract the URL from the line 46 | const urlMatch = line.match(/\[.*\]\((.*)\)/); 47 | if (urlMatch) { 48 | const articleUrl = urlMatch[1]; 49 | // Check if the article URL exists in the fetched reading list 50 | const existsInReadingList = readingList.some((article) => article.article.url === articleUrl); 51 | // If the article doesn't exist in the reading list, add it to removedArticles 52 | if (!existsInReadingList) { 53 | const titleMatch = line.match(/\[(.*)\]/); 54 | if (titleMatch) { 55 | const articleTitle = titleMatch[1]; 56 | removedArticles.push(articleTitle); 57 | } 58 | } 59 | return existsInReadingList; 60 | } 61 | // Preserve lines that are not article URLs 62 | return true; 63 | }) 64 | .join("\n"); 65 | // Log removed articles 66 | if (removedArticles.length > 0) { 67 | console.log(`Removed these articles from the reading list: ${removedArticles.join(", ")}`); 68 | } 69 | fs.writeFileSync(readmePath, updatedContent); 70 | (0, performGitActions_1.performGitActions)({ 71 | commitMessage, 72 | path: readmePath, 73 | branch 74 | }); 75 | core.notice(`Reading list synchronized successfully.`); 76 | } 77 | catch (error) { 78 | core.notice(`Failed to synchronize reading list: ${error.message}`); 79 | } 80 | } 81 | exports.synchronizeReadingList = synchronizeReadingList; 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devtogithub", 3 | "version": "1.0.0", 4 | "description": "Save your dev.to articles with all the necessary information on GitHub", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "npm test", 8 | "build": "tsc && ncc build dist/index.js", 9 | "workflow": "npm run build && node dist/index.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Anmol-Baranwal/DevtoGitHub.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/Anmol-Baranwal/DevtoGitHub/issues" 17 | }, 18 | "homepage": "https://github.com/Anmol-Baranwal/DevtoGitHub#readme", 19 | "keywords": [], 20 | "exports": { 21 | ".": "./dist/index.js" 22 | }, 23 | "author": "Anmol-Baranwal (https://github.com/Anmol-Baranwal)", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@actions/core": "^1.10.1", 27 | "@actions/github": "^6.0.0", 28 | "@types/node": "^20.11.5", 29 | "node-fetch": "^3.3.2", 30 | "typescript": "^5.3.3" 31 | }, 32 | "engines": { 33 | "node": "20.x" 34 | }, 35 | "dependencies": { 36 | "@actions/exec": "^1.1.1", 37 | "@vercel/ncc": "^0.38.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchDevToArticles } from "./utils/fetchDevToArticles" 2 | import { createMarkdownFile } from "./utils/createMarkdownFile" 3 | import * as core from "@actions/core" 4 | import { createReadingList } from "./utils/createReadingList" 5 | import { fetchDevToReadingList } from "./utils/fetchDevToReadingList" 6 | import { synchronizeReadingList } from "./utils/synchronizeReadingList" 7 | 8 | async function DevtoGitHub() { 9 | try { 10 | const apiKey = core.getInput("devApiKey") 11 | const outputDir = core.getInput("outputDir") || "./articles" // Default is the articles directory 12 | const outputDirReading = core.getInput("outputDirReading") || "./" // Default is the root directory 13 | const branch = core.getInput("branch") || "main" 14 | const readingList = core.getInput("readingList") === "true" || false 15 | const saveArticles = core.getInput("saveArticles") === "true" || false 16 | const synchronizeReadingListInput = 17 | core.getInput("synchronizeReadingList") === "true" || false 18 | 19 | if (saveArticles === true) { 20 | const articles = await fetchDevToArticles(apiKey) 21 | createMarkdownFile(articles, outputDir, branch, apiKey) 22 | core.notice("Articles fetched and saved successfully.") 23 | } else { 24 | core.notice(`skipping saving of articles`) 25 | } 26 | 27 | if (readingList === true) { 28 | const readingListArticles = await fetchDevToReadingList(apiKey) 29 | 30 | createReadingList(readingListArticles, outputDirReading, branch) 31 | 32 | if (synchronizeReadingListInput === true) { 33 | // synchronize reading list from DEV with readme 34 | synchronizeReadingList(readingListArticles, outputDirReading, branch) 35 | } else { 36 | core.notice(`skipping synchronization of reading list`) 37 | } 38 | } else { 39 | core.notice(`skipping saving reading list`) 40 | } 41 | } catch (error) { 42 | console.error("Error:", (error as Error).message) 43 | } 44 | } 45 | 46 | DevtoGitHub() 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface DevToArticle { 2 | id?: number 3 | title: string 4 | published_timestamp: string 5 | description: string 6 | cover_image?: string 7 | social_image?: string 8 | tag_list: string[] 9 | tags: string[] 10 | url: string 11 | positive_reactions_count: number 12 | public_reactions_count: number 13 | canonical_url?: string 14 | organization?: string 15 | series?: string 16 | body_markdown?: string 17 | edited_at?: string 18 | } 19 | 20 | export interface ReadingListArticle { 21 | id: number 22 | title: string 23 | readable_publish_date: string 24 | url: string 25 | cover_image: string 26 | canonical_url: string 27 | reading_time_minutes: number 28 | tags: string 29 | } 30 | 31 | export interface ReadingList { 32 | id?: number 33 | created_at: string 34 | article: ReadingListArticle 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/createArticlesReadme.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core" 2 | import * as fs from "fs" 3 | import { getFileNameFromTitle } from "./git" 4 | import { performGitActions } from "./performGitActions" 5 | 6 | const conventionalCommits = 7 | core.getInput("conventionalCommits") === "true" || true 8 | 9 | export async function createArticlesReadme( 10 | articles: any[], 11 | outputDir: string, 12 | branch: string 13 | ): Promise { 14 | // Create content for README.md 15 | let readmeContent = "" 16 | const readmePath = `${outputDir}/README.md` 17 | if (fs.existsSync(readmePath)) { 18 | readmeContent = fs.readFileSync(readmePath, "utf8") 19 | } 20 | 21 | const hasTableOfContentsHeading = readmeContent.includes( 22 | "# Table of Contents\n\n" 23 | ) 24 | 25 | // Set the commit message based on whether the heading exists 26 | let commitMessage = hasTableOfContentsHeading 27 | ? "update readme with table of contents" 28 | : "create readme with table of contents" 29 | 30 | if (!hasTableOfContentsHeading) { 31 | readmeContent = "# Table of Contents\n\n" 32 | } 33 | 34 | for (const article of articles) { 35 | const fileName = getFileNameFromTitle(article.title).trim() 36 | 37 | const fileLink = `./${fileName}.md` 38 | 39 | if (readmeContent.includes(`[${article.title}]`)) { 40 | console.log( 41 | `Skipping "${article.title}" because it already exists in the table of contents.` 42 | ) 43 | continue 44 | } 45 | 46 | // Add entry to README content 47 | readmeContent += `- [${article.title}](${fileLink.replace(/ /g, "%20")})\n` 48 | } 49 | 50 | // Write README.md 51 | fs.writeFileSync(readmePath, readmeContent) 52 | 53 | if (conventionalCommits) { 54 | commitMessage = `chore: ${commitMessage.toLowerCase()}` 55 | } 56 | 57 | performGitActions({ 58 | commitMessage, 59 | path: readmePath, 60 | branch, 61 | noticeMessage: "README.md file created and committed" 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/createMarkdownFile.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core" 2 | import * as fs from "fs" 3 | import { getFileNameFromTitle } from "./git" 4 | import { parseMarkdownContent } from "./parseMarkdownContent" 5 | import { fetchDevArticleUsingId } from "./fetchDevArticleUsingId" 6 | import { performGitActions } from "./performGitActions" 7 | import { createArticlesReadme } from "./createArticlesReadme" 8 | 9 | const conventionalCommits = 10 | core.getInput("conventionalCommits") === "true" || true 11 | 12 | export async function createMarkdownFile( 13 | articles: any[], 14 | outputDir: string, 15 | branch: string, 16 | apiKey: string 17 | ): Promise { 18 | // output directory must exist 19 | if (!fs.existsSync(outputDir)) { 20 | try { 21 | // Create the directory with necessary permissions 22 | fs.mkdirSync(outputDir) 23 | } catch (error) { 24 | core.setFailed( 25 | `Failed to create directory ${outputDir}: ${(error as Error).message}` 26 | ) 27 | return 28 | } 29 | } 30 | 31 | for (const article of articles) { 32 | const fileName = getFileNameFromTitle(article.title).trim() 33 | const filePath = `./${outputDir}/${fileName}.md` 34 | 35 | let commitMessage 36 | 37 | // Check if the markdown file already exists 38 | if (!fs.existsSync(filePath)) { 39 | commitMessage = `add ${fileName} markdown file` 40 | 41 | if (conventionalCommits) { 42 | commitMessage = `chore: ${commitMessage.toLowerCase()}` 43 | } 44 | 45 | const markdownContent = parseMarkdownContent(article) 46 | // Write markdown content to file 47 | fs.writeFileSync(filePath, markdownContent) 48 | 49 | performGitActions({ 50 | commitMessage, 51 | path: filePath, 52 | branch 53 | // noticeMessage: "Markdown file created and committed" 54 | }) 55 | 56 | // core.notice(`Markdown file created: ${filePath}`) 57 | } else { 58 | const existingContent = fs.readFileSync(filePath, "utf8") 59 | const fetchedArticle = await fetchDevArticleUsingId(article.id, apiKey) 60 | 61 | // Check if the article has been edited by comparing the existing content with the fetched article's content 62 | const newMarkdownContent = parseMarkdownContent(fetchedArticle, { 63 | option: "2" 64 | }) 65 | if (existingContent !== newMarkdownContent) { 66 | core.notice( 67 | `Article has been edited, updating the Markdown file content.` 68 | ) 69 | fs.writeFileSync(filePath, newMarkdownContent) 70 | 71 | commitMessage = `update ${fileName} markdown file with edited content` 72 | 73 | if (conventionalCommits) { 74 | commitMessage = `chore: ${commitMessage.toLowerCase()}` 75 | } 76 | 77 | performGitActions({ 78 | commitMessage, 79 | path: filePath, 80 | branch, 81 | noticeMessage: "Markdown file created and committed" 82 | }) 83 | } else { 84 | core.notice( 85 | `Markdown file already exists for "${article.title}" and it is not edited. Skipping.` 86 | ) 87 | } 88 | } 89 | } 90 | const tableOfContents = 91 | core.getInput("saveArticlesReadme") === "true" || false 92 | 93 | if (tableOfContents) { 94 | await createArticlesReadme(articles, outputDir, branch) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/createReadingList.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core" 2 | import * as fs from "fs" 3 | import { ReadingList } from "../types" 4 | import { performGitActions } from "./performGitActions" 5 | 6 | export async function createReadingList( 7 | articles: ReadingList[], 8 | outputDir: string, 9 | branch: string 10 | ): Promise { 11 | const readTime = core.getInput("readTime") === "true" || false 12 | 13 | const conventionalCommits = 14 | core.getInput("conventionalCommits") === "true" || true 15 | 16 | // Ensure the output directory exists 17 | if (!fs.existsSync(outputDir)) { 18 | try { 19 | fs.mkdirSync(outputDir) 20 | } catch (error) { 21 | core.setFailed( 22 | `Failed to create directory ${outputDir}: ${(error as Error).message}` 23 | ) 24 | return 25 | } 26 | } 27 | 28 | let existingContent = "" 29 | const readmePath = `./${outputDir}/README.md` 30 | 31 | if (fs.existsSync(readmePath)) { 32 | existingContent = fs.readFileSync(readmePath, "utf8") 33 | } else { 34 | fs.writeFileSync(readmePath, "") 35 | existingContent = fs.readFileSync(readmePath, "utf8") 36 | } 37 | 38 | const hasReadingListHeading = existingContent.includes("## Reading List") 39 | let commitMessage = hasReadingListHeading 40 | ? "update reading list" 41 | : "create reading list" 42 | 43 | if (conventionalCommits) { 44 | commitMessage = `chore: ${commitMessage.toLowerCase()}` 45 | } 46 | 47 | // Check if the reading list heading exists, if not add it 48 | if (!hasReadingListHeading) { 49 | existingContent += "\n
\n\n## Reading List\n\n" 50 | } 51 | 52 | // Add bullet points for each article 53 | for (const articleItem of articles) { 54 | const articleUrl = articleItem.article.url 55 | // url is used to avoid adding duplicate articles 56 | if (existingContent.includes(articleUrl)) { 57 | console.log( 58 | `Skipping article "${articleItem.article.title}" because it already exists in the reading list.` 59 | ) 60 | continue 61 | } 62 | 63 | if (readTime) { 64 | existingContent += `- [${articleItem.article.title}](${articleItem.article.url}) - ${articleItem.article.reading_time_minutes} minutes\n` 65 | } else { 66 | existingContent += `- [${articleItem.article.title}](${articleItem.article.url})\n` 67 | } 68 | } 69 | 70 | fs.writeFileSync(readmePath, existingContent) 71 | 72 | performGitActions({ 73 | commitMessage, 74 | path: readmePath, 75 | branch, 76 | noticeMessage: "Reading list file created and committed" 77 | }) 78 | 79 | core.notice(`Reading list updated in README.md`) 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/fetchDevArticleUsingId.ts: -------------------------------------------------------------------------------- 1 | import { DevToArticle } from "../types" 2 | 3 | export async function fetchDevArticleUsingId( 4 | id: number, 5 | apiKey: string 6 | ): Promise { 7 | const apiUrl = `https://dev.to/api/articles/${id}` 8 | const headers: { [key: string]: string } = { 9 | "Content-Type": "application/json", 10 | "api-key": apiKey 11 | } 12 | 13 | const response = await fetch(apiUrl, { headers }) 14 | 15 | if (!response.ok) { 16 | throw new Error(`Failed to fetch article. Status: ${response.status}`) 17 | } 18 | 19 | const article = await response.json() 20 | 21 | return article as DevToArticle 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/fetchDevToArticles.ts: -------------------------------------------------------------------------------- 1 | import { DevToArticle } from "../types" 2 | import fetch from "node-fetch" 3 | 4 | export async function fetchDevToArticles( 5 | apiKey: string, 6 | per_page?: number 7 | ): Promise { 8 | if (per_page === undefined) per_page = 999 // default is 30 9 | let page = 1 10 | let articles: DevToArticle[] = [] 11 | 12 | while (true) { 13 | const apiUrl = `https://dev.to/api/articles/me?page=${page}&per_page=${per_page}` 14 | 15 | const headers: { [key: string]: string } = { 16 | "Content-Type": "application/json", 17 | "api-key": apiKey 18 | } 19 | 20 | const response = await fetch(apiUrl, { headers }) 21 | 22 | if (!response.ok) { 23 | throw new Error(`Failed to fetch articles. Status: ${response.status}`) 24 | } 25 | 26 | const pageArticles = (await response.json()) as DevToArticle[] 27 | 28 | if (pageArticles.length === 0) { 29 | break // No more articles left 30 | } 31 | 32 | articles = articles.concat(pageArticles) 33 | page++ 34 | } 35 | 36 | return articles 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/fetchDevToReadingList.ts: -------------------------------------------------------------------------------- 1 | import { ReadingList } from "../types" 2 | import fetch from "node-fetch" 3 | import * as core from "@actions/core" 4 | 5 | const filteredArticles = ( 6 | articles: ReadingList[], 7 | excludeTags: string[], 8 | mustIncludeTags: string[] 9 | ): ReadingList[] => { 10 | if (excludeTags.length === 0) { 11 | // No filtering if excludeTags is empty 12 | return articles 13 | } 14 | 15 | return articles.filter((articleItem) => { 16 | const articleTags = articleItem.article.tags 17 | 18 | const hasExcludedTag = excludeTags.some((tag) => articleTags.includes(tag)) 19 | const hasMustIncludeTag = 20 | mustIncludeTags.length !== 0 && 21 | mustIncludeTags.some((tag) => articleTags.includes(tag)) 22 | 23 | const shouldInclude = hasMustIncludeTag || !hasExcludedTag 24 | 25 | return shouldInclude 26 | }) 27 | } 28 | 29 | export async function fetchDevToReadingList( 30 | apiKey: string, 31 | per_page?: number 32 | ): Promise { 33 | if (per_page === undefined) per_page = 30 // Default per page value is 30 34 | let page = 1 35 | let readingList: ReadingList[] = [] 36 | 37 | while (true) { 38 | const apiUrl = `https://dev.to/api/readinglist?page=${page}&per_page=${per_page}` 39 | 40 | const headers: { [key: string]: string } = { 41 | "Content-Type": "application/json", 42 | "api-key": apiKey 43 | } 44 | 45 | const response = await fetch(apiUrl, { headers }) 46 | 47 | if (!response.ok) { 48 | throw new Error( 49 | `Failed to fetch reading list. Status: ${response.status}` 50 | ) 51 | } 52 | 53 | const articles = (await response.json()) as ReadingList[] 54 | 55 | if (articles.length === 0) { 56 | break // break when no more articles left 57 | } 58 | 59 | readingList = readingList.concat(articles) 60 | page++ 61 | } 62 | 63 | core.notice("Reading list fetched successfully.") 64 | 65 | const excludeTags = core 66 | .getInput("excludeTags") 67 | .split(",") 68 | .map((tag) => tag.trim()) 69 | 70 | const mustIncludeTags = core 71 | .getInput("mustIncludeTags") 72 | .split(",") 73 | .map((tag) => tag.trim()) 74 | 75 | const filteredReadingList = filteredArticles( 76 | readingList, 77 | excludeTags, 78 | mustIncludeTags 79 | ) 80 | 81 | return filteredReadingList 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/git.ts: -------------------------------------------------------------------------------- 1 | import * as exec from "@actions/exec" 2 | import * as core from "@actions/core" 3 | 4 | // generate a valid file name using the title 5 | export function getFileNameFromTitle(title: string): string { 6 | // Replace special characters other than apostrophes and hyphens with spaces 7 | return title 8 | .replace(/[^\w\s'-]/gi, " ") 9 | .replace(/\s+/g, " ") 10 | .toLowerCase() 11 | } 12 | 13 | export async function gitAdd(filePath: string): Promise { 14 | try { 15 | await exec.exec("git", ["add", filePath]) 16 | } catch (error) { 17 | core.notice(`Failed to complete git add: ${(error as Error).message}`) 18 | } 19 | } 20 | 21 | export async function gitCommit( 22 | message: string, 23 | filePath: string 24 | ): Promise { 25 | try { 26 | const statusOutput = await exec.getExecOutput("git", [ 27 | "status", 28 | "--porcelain", 29 | filePath 30 | ]) 31 | if (statusOutput.stdout.trim() === "") { 32 | core.notice(`No changes to commit for file ${filePath}`) 33 | return 34 | } 35 | await exec.exec("git", ["commit", "-m", message, filePath]) 36 | } catch (error) { 37 | core.notice(`Failed to complete git commit: ${(error as Error).message}`) 38 | } 39 | } 40 | 41 | export async function gitPush(branch: string): Promise { 42 | try { 43 | await exec.exec("git", ["push", "origin", `HEAD:${branch}`]) 44 | } catch (error) { 45 | core.notice(`Failed to complete git push: ${(error as Error).message}`) 46 | } 47 | } 48 | 49 | export async function gitConfig(): Promise { 50 | try { 51 | await exec.exec("git", [ 52 | "config", 53 | "--global", 54 | "user.email", 55 | "actions@github.com" 56 | ]) 57 | await exec.exec("git", [ 58 | "config", 59 | "--global", 60 | "user.name", 61 | "GitHub Actions" 62 | ]) 63 | } catch (error) { 64 | core.notice( 65 | `Failed to set up Git configuration: ${(error as Error).message}` 66 | ) 67 | } 68 | } 69 | 70 | export async function gitPull(branch: string): Promise { 71 | try { 72 | await exec.exec("git", ["pull", "origin", branch]) 73 | } catch (error) { 74 | core.notice( 75 | `Failed to pull changes from ${branch}: ${(error as Error).message}` 76 | ) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/parseMarkdownContent.ts: -------------------------------------------------------------------------------- 1 | import { DevToArticle } from "../types" 2 | 3 | type Choice = { 4 | option?: "1" | "2" 5 | } 6 | 7 | export function parseMarkdownContent( 8 | article: DevToArticle, 9 | choice: Choice = { option: "1" } 10 | ): string { 11 | const coverImageBanner = article.cover_image 12 | ? `Cover Image` 13 | : "" 14 | 15 | const formattedTimestamp = formatTimestamp(article.published_timestamp) 16 | 17 | // the api response of article fetched using id has different fields compared to api response of user's article. 18 | const formattedTags = 19 | choice && choice.option === "2" 20 | ? article.tags.join(", ") 21 | : article.tag_list.map((tag) => `\`${tag}\``).join(", ") 22 | 23 | return `\ 24 | ${coverImageBanner} 25 |
26 | 27 | # ${article.title} 28 | 29 | **Tags:** ${formattedTags} 30 | 31 | **Published At:** ${formattedTimestamp} 32 | 33 | **URL:** [${article.url}](${article.url}) 34 | 35 |
36 | ${article.body_markdown} 37 | ` 38 | } 39 | 40 | const formatTimestamp = (timestamp: string) => { 41 | const date = new Date(timestamp) 42 | return date.toLocaleString() 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/performGitActions.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core" 2 | import { gitAdd, gitCommit, gitConfig, gitPull, gitPush } from "./git" 3 | 4 | interface GitActionsProps { 5 | commitMessage: string 6 | path: string 7 | branch: string 8 | noticeMessage?: string 9 | } 10 | 11 | export async function performGitActions({ 12 | commitMessage, 13 | path, 14 | branch, 15 | noticeMessage 16 | }: GitActionsProps): Promise { 17 | try { 18 | await gitConfig() 19 | await gitAdd(path) 20 | await gitCommit(commitMessage, path) 21 | await gitPull(branch) 22 | await gitPush(branch) 23 | 24 | if (noticeMessage) core.notice(noticeMessage) 25 | } catch (error) { 26 | core.notice( 27 | `Failed to commit and push changes: ${(error as Error).message}` 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/savedArticlesReadme.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Anmol-Baranwal/DevtoGitHub/6b94edc927e7086405ddc5cc0f71bc1ec12673c6/src/utils/savedArticlesReadme.ts -------------------------------------------------------------------------------- /src/utils/synchronizeReadingList.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { ReadingList } from "../types" 3 | import * as core from "@actions/core" 4 | import { performGitActions } from "./performGitActions" 5 | 6 | export async function synchronizeReadingList( 7 | readingList: ReadingList[], 8 | outputDir: string, 9 | branch: string 10 | ): Promise { 11 | const readmePath = `./${outputDir}/README.md` 12 | 13 | let commitMessage = "synchronize reading list" 14 | 15 | const conventionalCommits = 16 | core.getInput("conventionalCommits") === "true" || true 17 | 18 | if (conventionalCommits) { 19 | commitMessage = `chore: ${commitMessage.toLowerCase()}` 20 | } 21 | 22 | try { 23 | const existingContent = fs.readFileSync(readmePath, "utf8") 24 | 25 | // For logging names of removed articles 26 | const removedArticles: string[] = [] 27 | 28 | // Iterate each line in the readme 29 | let updatedContent = existingContent 30 | .split("\n") 31 | .filter((line) => { 32 | // Extract the URL from the line 33 | const urlMatch = line.match(/\[.*\]\((.*)\)/) 34 | if (urlMatch) { 35 | const articleUrl = urlMatch[1] 36 | 37 | // Check if the article URL exists in the fetched reading list 38 | const existsInReadingList = readingList.some( 39 | (article) => article.article.url === articleUrl 40 | ) 41 | 42 | // If the article doesn't exist in the reading list, add it to removedArticles 43 | if (!existsInReadingList) { 44 | const titleMatch = line.match(/\[(.*)\]/) 45 | if (titleMatch) { 46 | const articleTitle = titleMatch[1] 47 | removedArticles.push(articleTitle) 48 | } 49 | } 50 | 51 | return existsInReadingList 52 | } 53 | // Preserve lines that are not article URLs 54 | return true 55 | }) 56 | .join("\n") 57 | 58 | // Log removed articles 59 | if (removedArticles.length > 0) { 60 | console.log( 61 | `Removed these articles from the reading list: ${removedArticles.join( 62 | ", " 63 | )}` 64 | ) 65 | } 66 | 67 | fs.writeFileSync(readmePath, updatedContent) 68 | 69 | performGitActions({ 70 | commitMessage, 71 | path: readmePath, 72 | branch 73 | }) 74 | 75 | core.notice(`Reading list synchronized successfully.`) 76 | } catch (error) { 77 | core.notice( 78 | `Failed to synchronize reading list: ${(error as Error).message}` 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "commonjs", 5 | "outDir": "dist", // use this for build under script in package.json 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | } 11 | } --------------------------------------------------------------------------------