├── .husky └── pre-commit ├── mise.toml ├── .npmrc ├── .github ├── FUNDING.yml ├── release.yml ├── workflows │ ├── auto-assign-action.yml │ ├── auto-approve.yml │ ├── ci.yml │ ├── auto-release.yml │ └── linter.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── auto_assign.yml └── pull_request_template.md ├── src ├── core │ ├── Converter.ts │ ├── converters │ │ ├── EmbedsConverter.ts │ │ ├── FilenameConverter.ts │ │ ├── WikiLinkConverter.ts │ │ ├── FootnotesConverter.ts │ │ ├── CommentsConverter.ts │ │ ├── CurlyBraceConverter.ts │ │ ├── CalloutConverter.ts │ │ ├── ResourceLinkConverter.ts │ │ └── FrontMatterConverter.ts │ ├── ObsidianRegex.ts │ ├── ConverterChain.ts │ ├── types │ │ └── types.ts │ ├── validation.ts │ ├── fp.ts │ └── utils │ │ └── utils.ts ├── tests │ ├── ObsidianRegex.test.ts │ ├── core │ │ ├── converters │ │ │ ├── EmbedsConverter.test.ts │ │ │ ├── CommentsConverter.test.ts │ │ │ ├── CurlyBraceConverter.test.ts │ │ │ ├── FilenameConverter.test.ts │ │ │ ├── FootnotesConverter.test.ts │ │ │ ├── WikiLinkConverter.test.ts │ │ │ ├── ResourceLinkConverter.test.ts │ │ │ ├── CalloutConverter.test.ts │ │ │ └── FrontMatterConverter.test.ts │ │ └── utils │ │ │ └── utils.test.ts │ ├── __mocks__ │ │ └── obsidian.ts │ ├── ConverterChain.test.ts │ ├── platforms │ │ ├── docusaurus │ │ │ └── docusaurus.test.ts │ │ └── DateExtractionPattern.test.ts │ ├── types.test.ts │ └── fp.test.ts ├── platforms │ ├── docusaurus │ │ ├── settings │ │ │ └── DocusaurusSettings.ts │ │ ├── DateExtractionPattern.ts │ │ └── docusaurus.ts │ └── jekyll │ │ ├── settings │ │ └── JekyllSettings.ts │ │ └── chirpy.ts ├── main.ts └── settings.ts ├── .cz.toml ├── styles.css ├── manifest.json ├── .gitignore ├── jest.config.js ├── tsconfig.json ├── .prettierrc ├── version-bump.mjs ├── versions.json ├── sweep.yaml ├── LICENSE ├── esbuild.config.mjs ├── package.json ├── eslint.config.mjs ├── README.md └── CODE_OF_CONDUCT.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "23" 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | sign-git-tag="true" 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: songkg7 4 | -------------------------------------------------------------------------------- /src/core/Converter.ts: -------------------------------------------------------------------------------- 1 | export type Contents = string; 2 | 3 | export interface Converter { 4 | convert(input: Contents): Contents; 5 | } 6 | -------------------------------------------------------------------------------- /.cz.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | name = "cz_conventional_commits" 3 | tag_format = "$version" 4 | version_scheme = "semver" 5 | version_provider = "npm" 6 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | changelog: 3 | categories: 4 | - title: 🏕 Features 5 | labels: 6 | - '*' 7 | exclude: 8 | labels: 9 | - dependencies 10 | - title: 👒 Dependencies 11 | labels: 12 | - dependencies 13 | -------------------------------------------------------------------------------- /.github/workflows/auto-assign-action.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto assign' 2 | on: 3 | pull_request_target: 4 | types: [opened, ready_for_review] 5 | 6 | jobs: 7 | add-reviews: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: kentaro-m/auto-assign-action@v2.0.0 11 | -------------------------------------------------------------------------------- /src/core/converters/EmbedsConverter.ts: -------------------------------------------------------------------------------- 1 | import { Converter } from '../Converter'; 2 | import { ObsidianRegex } from '../ObsidianRegex'; 3 | 4 | export class EmbedsConverter implements Converter { 5 | convert(input: string): string { 6 | return input.replace(ObsidianRegex.EMBEDDED_LINK, '$1'); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/converters/FilenameConverter.ts: -------------------------------------------------------------------------------- 1 | export const convertFileName = (filename: string): string => 2 | filename 3 | .replace('.md', '') 4 | .replace(/\s/g, '-') 5 | .replace(/[^a-zA-Z0-9-\uAC00-\uD7A3]/g, ''); 6 | 7 | export const removeTempPrefix = (filename: string): string => 8 | filename.replace('o2-temp', ''); 9 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "o2", 3 | "name": "O2", 4 | "version": "2.4.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "This is a plugin to make obsidian markdown syntax compatible with other markdown syntax.", 7 | "author": "haril song", 8 | "authorUrl": "https://github.com/songkg7", 9 | "fundingUrl": "", 10 | "isDesktopOnly": true 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Ask a question 3 | url: https://github.com/songkg7/o2/discussions/categories/q-a 4 | about: Please ask and answer questions here. 5 | - name: Feature request/idea 6 | url: https://github.com/songkg7/o2/discussions/categories/ideas 7 | about: All not yet well-defined feature requests must be firstly brainstormed in "feature ideas". 8 | -------------------------------------------------------------------------------- /src/core/ObsidianRegex.ts: -------------------------------------------------------------------------------- 1 | export const ObsidianRegex = { 2 | ATTACHMENT_LINK: /!\[\[([^|\]]+)\.(\w+)\|?(\d*)x?(\d*)]](\n{0,2}(_.*_))?/g, 3 | EMBEDDED_LINK: /!\[\[([\w\s-]+)[#^]*([\w\s]*)]]/g, 4 | WIKI_LINK: /(? \[!(.*)](.*)?\n(>.*)/gi, 6 | SIMPLE_FOOTNOTE: /\[\^(\d+)]/g, 7 | COMMENT: /%%(.*?)%%/g, 8 | DOUBLE_CURLY_BRACES: /{{(.*?)}}/g, 9 | } as const; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | coverage/ 25 | mock-*/ 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | transform: { 5 | '^.+\\.ts?$': 'ts-jest', 6 | }, 7 | transformIgnorePatterns: ['/node_modules'], 8 | moduleDirectories: ['node_modules', 'src'], 9 | collectCoverage: true, 10 | coverageReporters: ['text', 'cobertura'], 11 | moduleNameMapper: { 12 | '^obsidian$': '/src/tests/__mocks__/obsidian.ts', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/core/converters/WikiLinkConverter.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianRegex } from '../ObsidianRegex'; 2 | import { Converter } from '../Converter'; 3 | 4 | export class WikiLinkConverter implements Converter { 5 | convert(input: string): string { 6 | return input.replace(ObsidianRegex.WIKI_LINK, (match, p1, p2) => 7 | p2 ? p2 : p1, 8 | ); 9 | } 10 | } 11 | 12 | export const convertWikiLink = (input: string) => 13 | input.replace(ObsidianRegex.WIKI_LINK, (match, p1, p2) => (p2 ? p2 : p1)); 14 | -------------------------------------------------------------------------------- /src/core/converters/FootnotesConverter.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianRegex } from '../ObsidianRegex'; 2 | import { Converter } from '../Converter'; 3 | 4 | export class FootnotesConverter implements Converter { 5 | convert(input: string): string { 6 | return input.replace(ObsidianRegex.SIMPLE_FOOTNOTE, (match, key) => { 7 | return `[^fn-nth-${key}]`; 8 | }); 9 | } 10 | } 11 | 12 | export const convertFootnotes = (input: string) => 13 | input.replace( 14 | ObsidianRegex.SIMPLE_FOOTNOTE, 15 | (match, key) => `[^fn-nth-${key}]`, 16 | ); 17 | -------------------------------------------------------------------------------- /src/core/converters/CommentsConverter.ts: -------------------------------------------------------------------------------- 1 | import { Converter } from '../Converter'; 2 | import { ObsidianRegex } from '../ObsidianRegex'; 3 | 4 | export class CommentsConverter implements Converter { 5 | public convert(input: string): string { 6 | return input.replace( 7 | ObsidianRegex.COMMENT, 8 | (match, comments) => ``, 9 | ); 10 | } 11 | } 12 | 13 | export const convertComments = (input: string) => 14 | input.replace( 15 | ObsidianRegex.COMMENT, 16 | (match, comments) => ``, 17 | ); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "paths": { 5 | "src": ["src/*"] 6 | }, 7 | "baseUrl": ".", 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "module": "ESNext", 11 | "target": "ES6", 12 | "allowJs": true, 13 | "noImplicitAny": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "strictNullChecks": true, 18 | "lib": ["DOM", "ES5", "ES6", "ES7"] 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "arrowParens": "avoid", 5 | "bracketSpacing": true, 6 | "endOfLine": "lf", 7 | "htmlWhitespaceSensitivity": "css", 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "preserve", 11 | "quoteProps": "as-needed", 12 | "singleQuote": true, 13 | "tabWidth": 2, 14 | "useTabs": false, 15 | "requirePragma": false, 16 | "insertPragma": false, 17 | "overrides": [ 18 | { 19 | "files": "*.json", 20 | "options": { 21 | "printWidth": 200 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/tests/ObsidianRegex.test.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianRegex } from '../core/ObsidianRegex'; 2 | 3 | describe('Image link', () => { 4 | it('should separate image name and size', () => { 5 | const context = '![[test.png|100]]'; 6 | const result = context.replace(ObsidianRegex.ATTACHMENT_LINK, '$1 $2 $3'); 7 | 8 | expect(result).toEqual('test png 100'); 9 | }); 10 | 11 | it('extract only image name', () => { 12 | const context = '![[test.png]]'; 13 | const result = context.replace(ObsidianRegex.ATTACHMENT_LINK, '$1 $2'); 14 | 15 | expect(result).toEqual('test png'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/tests/core/converters/EmbedsConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { EmbedsConverter } from '../../../core/converters/EmbedsConverter'; 2 | 3 | const converter = new EmbedsConverter(); 4 | 5 | describe('convert called', () => { 6 | it.each([ 7 | ['![[test]]', 'test'], 8 | ['![[Obsidian#What is Obsidian]]', 'Obsidian'], 9 | ['![[Obsidian#^asdf1234]]', 'Obsidian'], 10 | ['![[Obsidian#What is Obsidian]]', 'Obsidian'], 11 | ])( 12 | 'should remove brackets if does not exist extension keyword', 13 | (input, expected) => { 14 | expect(converter.convert(input)).toEqual(expected); 15 | }, 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /src/core/ConverterChain.ts: -------------------------------------------------------------------------------- 1 | import { Contents, Converter } from './Converter'; 2 | 3 | export class ConverterChain { 4 | private converters: Converter[] = []; 5 | 6 | private constructor() {} 7 | 8 | public static create(): ConverterChain { 9 | return new ConverterChain(); 10 | } 11 | 12 | public chaining(converter: Converter): ConverterChain { 13 | this.converters.push(converter); 14 | return this; 15 | } 16 | 17 | public converting(input: Contents): Contents { 18 | let result = input; 19 | for (const converter of this.converters) { 20 | result = converter.convert(result); 21 | } 22 | return result; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | --- 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'monthly' 12 | labels: 13 | - 'dependencies' 14 | groups: 15 | dev-dependencies: 16 | patterns: 17 | - '@type*' 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependabot auto-approve 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | issues: write 8 | repository-projects: write 9 | 10 | jobs: 11 | dependabot: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.actor == 'dependabot[bot]' }} 14 | steps: 15 | - name: Dependabot metadata 16 | id: metadata 17 | uses: dependabot/fetch-metadata@v1 18 | with: 19 | github-token: '${{ secrets.GITHUB_TOKEN }}' 20 | - name: Approve a PR 21 | run: gh pr review --approve "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /src/tests/core/converters/CommentsConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { CommentsConverter } from '../../../core/converters/CommentsConverter'; 2 | 3 | const converter = new CommentsConverter(); 4 | 5 | describe('CommentConverter', () => { 6 | it('should convert a comment', () => { 7 | const input = '%%This is a comment%%'; 8 | const expected = ''; 9 | const actual = converter.convert(input); 10 | expect(actual).toEqual(expected); 11 | }); 12 | 13 | it('should convert multiple comments', () => { 14 | const input = '%%This is a comment%% %%This is another comment%%'; 15 | const expected = ' '; 16 | const actual = converter.convert(input); 17 | expect(actual).toEqual(expected); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: author 6 | 7 | # A list of reviewers to be added to pull requests (GitHub user name) 8 | reviewers: 9 | - songkg7 10 | 11 | # A number of reviewers added to the pull request 12 | # Set 0 to add all the reviewers (default: 0) 13 | numberOfReviewers: 0 14 | # A list of assignees, overrides reviewers if set 15 | # assignees: 16 | # - assigneeA 17 | 18 | # A number of assignees to add to the pull request 19 | # Set to 0 to add all of the assignees. 20 | # Uses numberOfReviewers if unset. 21 | # numberOfAssignees: 2 22 | 23 | # A list of keywords to be skipped the process that add reviewers if pull requests include it 24 | # skipKeywords: 25 | # - wip 26 | -------------------------------------------------------------------------------- /src/tests/core/converters/CurlyBraceConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { CurlyBraceConverter } from '../../../core/converters/CurlyBraceConverter'; 2 | 3 | const activatedConverter = new CurlyBraceConverter(true); 4 | const deactivatedConverter = new CurlyBraceConverter(false); 5 | 6 | describe('CurlyBraceConverter', () => { 7 | it('should convert double curly braces to raw tag', () => { 8 | const input = 'This is a {{test}}.'; 9 | const expected = 'This is a {% raw %}{{test}}{% endraw %}.'; 10 | expect(activatedConverter.convert(input)).toEqual(expected); 11 | }); 12 | 13 | it('should not convert double curly braces to raw tag', () => { 14 | const input = 'This is a {{test}}.'; 15 | const expected = 'This is a {{test}}.'; 16 | expect(deactivatedConverter.convert(input)).toEqual(expected); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.1.0": "0.15.0", 5 | "1.1.1": "0.15.0", 6 | "1.2.0": "0.15.0", 7 | "1.2.1": "0.15.0", 8 | "1.3.0": "0.15.0", 9 | "1.4.0": "0.15.0", 10 | "1.4.1": "0.15.0", 11 | "1.5.0": "0.15.0", 12 | "1.6.0": "0.15.0", 13 | "1.6.1": "0.15.0", 14 | "1.6.2": "0.15.0", 15 | "1.7.0": "0.15.0", 16 | "1.7.1": "0.15.0", 17 | "1.8.0": "0.15.0", 18 | "1.8.1": "0.15.0", 19 | "1.9.0": "0.15.0", 20 | "1.9.1": "0.15.0", 21 | "1.9.2": "0.15.0", 22 | "1.9.3": "0.15.0", 23 | "2.0.0": "0.15.0", 24 | "2.0.1": "0.15.0", 25 | "2.0.2": "0.15.0", 26 | "2.0.3": "0.15.0", 27 | "2.1.0": "0.15.0", 28 | "2.2.0": "0.15.0", 29 | "2.2.1": "0.15.0", 30 | "2.3.0": "0.15.0", 31 | "2.3.1": "0.15.0", 32 | "2.4.0": "0.15.0", 33 | "2.4.1": "0.15.0" 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. MacOS] 29 | - Obsidian version [e.g 1.14.1] 30 | - O2 Plugin version [e.g. 1.2.x] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /sweep.yaml: -------------------------------------------------------------------------------- 1 | # Sweep AI turns bug fixes & feature requests into code changes (https://sweep.dev) 2 | # For details on our config file, check out our docs at https://docs.sweep.dev 3 | 4 | # If you use this be sure to frequently sync your default branch(main, master) to dev. 5 | branch: 'main' 6 | # By default Sweep will read the logs and outputs from your existing Github Actions. To disable this, set this to false. 7 | gha_enabled: True 8 | # This is the description of your project. It will be used by sweep when creating PRs. You can tell Sweep what's unique about your project, what frameworks you use, or anything else you want. 9 | # Here's an example: sweepai/sweep is a python project. The main api endpoints are in sweepai/api.py. Write code that adheres to PEP8. 10 | description: '' 11 | # Default Values: https://github.com/sweepai/sweep/blob/main/sweep.yaml 12 | -------------------------------------------------------------------------------- /src/core/converters/CurlyBraceConverter.ts: -------------------------------------------------------------------------------- 1 | import { Converter } from '../Converter'; 2 | import { ObsidianRegex } from '../ObsidianRegex'; 3 | 4 | export class CurlyBraceConverter implements Converter { 5 | private readonly isEnable: boolean; 6 | 7 | constructor(isEnable = false) { 8 | this.isEnable = isEnable; 9 | } 10 | 11 | public convert(input: string): string { 12 | if (!this.isEnable) { 13 | return input; 14 | } 15 | return input.replace( 16 | ObsidianRegex.DOUBLE_CURLY_BRACES, 17 | (match, content) => `{% raw %}${match}{% endraw %}`, 18 | ); 19 | } 20 | } 21 | 22 | export const convertCurlyBrace = (isEnable: boolean, input: string) => { 23 | if (!isEnable) { 24 | return input; 25 | } 26 | return input.replace( 27 | ObsidianRegex.DOUBLE_CURLY_BRACES, 28 | (match, content) => `{% raw %}${match}{% endraw %}`, 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | run: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@main 17 | - name: Set up Node 18 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 18 21 | - name: Install dependencies 22 | run: npm install 23 | - name: Run tests and collect coverage 24 | run: npm run test 25 | - uses: codecov/codecov-action@v5 26 | with: 27 | # fail_ci_if_error: true # optional (default = false) 28 | files: ./coverage/cobertura-coverage.xml # optional 29 | # flags: unittests # optional 30 | # name: codecov-umbrella # optional 31 | token: ${{ secrets.CODECOV_TOKEN }} # required 32 | verbose: true # optional (default = false) 33 | -------------------------------------------------------------------------------- /src/platforms/docusaurus/settings/DocusaurusSettings.ts: -------------------------------------------------------------------------------- 1 | import { O2PluginSettings } from '../../../settings'; 2 | import { DateExtractionPattern } from '../DateExtractionPattern'; 3 | 4 | export default class DocusaurusSettings implements O2PluginSettings { 5 | docusaurusPath: string; 6 | dateExtractionPattern: string = 'FOLDER_NAMED_BY_DATE'; 7 | authors: string; 8 | 9 | targetPath(): string { 10 | return `${this.docusaurusPath}/blog`; 11 | } 12 | 13 | resourcePath(): string { 14 | return `${this.docusaurusPath}/static/img`; 15 | } 16 | 17 | afterPropertiesSet(): boolean { 18 | return ( 19 | this.docusaurusPath !== undefined && this.docusaurusPath.length !== 0 20 | ); 21 | } 22 | 23 | pathReplacer = ( 24 | year: string, 25 | month: string, 26 | day: string, 27 | title: string, 28 | ): string => { 29 | const patternInterface = DateExtractionPattern[this.dateExtractionPattern]; 30 | return patternInterface.replacer(year, month, day, title); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/auto-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release O2 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | tags: 8 | - '*' 9 | branches: 10 | - main 11 | 12 | permissions: write-all 13 | 14 | env: 15 | PLUGIN_NAME: o2 16 | 17 | jobs: 18 | build: 19 | if: contains(github.ref, 'refs/tags/') 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | 28 | - name: Build 29 | id: build 30 | run: | 31 | npm install 32 | npm run build 33 | 34 | - name: Release 35 | id: release 36 | uses: softprops/action-gh-release@v2 37 | with: 38 | draft: false 39 | prerelease: false 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | generate_release_notes: true 42 | files: | 43 | main.js 44 | manifest.json 45 | styles.css 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Haril Song 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 | -------------------------------------------------------------------------------- /src/tests/core/converters/FilenameConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { convertFileName } from '../../../core/converters/FilenameConverter'; 2 | 3 | describe('FilenameConverter', () => { 4 | it('should remove extension', () => { 5 | const filename = convertFileName('test.md'); 6 | expect(filename).toEqual('test'); 7 | }); 8 | 9 | it('should replace space to -', () => { 10 | const filename = convertFileName('This is Obsidian.md'); 11 | expect(filename).toEqual('This-is-Obsidian'); 12 | }); 13 | 14 | it('should convert Korean characters', () => { 15 | const filename = convertFileName('이것은 옵시디언입니다.md'); 16 | expect(filename).toEqual('이것은-옵시디언입니다'); 17 | }); 18 | 19 | it.each([ 20 | ['This is Obsidian.md', 'This-is-Obsidian'], 21 | ['This is Obsidian!!.md', 'This-is-Obsidian'], 22 | [ 23 | 'Obsidian, awesome note-taking tool.md', 24 | 'Obsidian-awesome-note-taking-tool', 25 | ], 26 | ['Obsidian(Markdown Editor).md', 'ObsidianMarkdown-Editor'], 27 | ])('should remove special characters', (filename, expected) => { 28 | const result = convertFileName(filename); 29 | expect(result).toEqual(expected); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Checklist 4 | 5 | Please check if your PR fulfills the following requirements: 6 | 7 | - [ ] Tests for the changes have been added (for bugfixes / features) 8 | 9 | ## PR Type 10 | 11 | What kind of change does this PR introduce? 12 | 13 | 14 | 15 | - [ ] Bugfix 16 | - [ ] Feature 17 | - [ ] Code style update (formatting, local variables) 18 | - [ ] Refactoring (no functional changes) 19 | - [ ] Build related changes 20 | - [ ] CI related changes 21 | - [ ] Documentation content changes 22 | - [ ] Other... Please describe: 23 | 24 | ## What is the current behavior? 25 | 26 | 27 | 28 | 29 | Issue Number: N/A 30 | 31 | ## What is the new behavior? 32 | 33 | ## Does this PR introduce a breaking change? 34 | 35 | - [ ] Yes 36 | - [ ] No 37 | 38 | 39 | 40 | ## Other information 41 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | const context = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ['src/main.ts'], 18 | bundle: true, 19 | external: [ 20 | 'obsidian', 21 | 'electron', 22 | '@codemirror/autocomplete', 23 | '@codemirror/collab', 24 | '@codemirror/commands', 25 | '@codemirror/language', 26 | '@codemirror/lint', 27 | '@codemirror/search', 28 | '@codemirror/state', 29 | '@codemirror/view', 30 | '@lezer/common', 31 | '@lezer/highlight', 32 | '@lezer/lr', 33 | ...builtins, 34 | ], 35 | format: 'cjs', 36 | target: 'es2018', 37 | logLevel: 'info', 38 | sourcemap: prod ? false : 'inline', 39 | treeShaking: true, 40 | outfile: 'main.js', 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint Code Base 3 | 4 | on: 5 | push: 6 | branches-ignore: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | build: 14 | name: Lint Code Base 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | packages: read 20 | # To report GitHub Actions status checks 21 | statuses: write 22 | 23 | steps: 24 | - name: Checkout Code 25 | uses: actions/checkout@v4 26 | with: 27 | # Full git history is needed to get a proper 28 | # list of changed files within `super-linter` 29 | fetch-depth: 0 30 | 31 | - name: Super-linter 32 | uses: super-linter/super-linter@v7.2.0 # x-release-please-version 33 | env: 34 | # To report GitHub Actions status checks 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | VALIDATE_JAVASCRIPT_STANDARD: 'false' 37 | VALIDATE_TYPESCRIPT_STANDARD: 'false' 38 | VALIDATE_CHECKOV: 'false' 39 | VALIDATE_JAVASCRIPT_ES: 'false' 40 | VALIDATE_TYPESCRIPT_ES: 'false' 41 | VALIDATE_JSON: 'false' 42 | VALIDATE_JSON_PRETTIER: 'false' 43 | -------------------------------------------------------------------------------- /src/tests/core/converters/FootnotesConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertFootnotes, 3 | FootnotesConverter, 4 | } from '../../../core/converters/FootnotesConverter'; 5 | 6 | const converter = new FootnotesConverter(); 7 | 8 | describe('FootnotesConverter', () => { 9 | it('should convert simple footnotes', () => { 10 | const contents = ` 11 | # Hello World 12 | 13 | This is a simple footnote[^1]. next footnote[^2]. 14 | 15 | [^1]: meaningful 16 | 17 | [^2]: meaningful 2 18 | 19 | `; 20 | 21 | const expected = ` 22 | # Hello World 23 | 24 | This is a simple footnote[^fn-nth-1]. next footnote[^fn-nth-2]. 25 | 26 | [^fn-nth-1]: meaningful 27 | 28 | [^fn-nth-2]: meaningful 2 29 | 30 | `; 31 | const actual = converter.convert(contents); 32 | expect(actual).toEqual(expected); 33 | }); 34 | }); 35 | 36 | describe('convertFootnotes', () => { 37 | it('should convert simple footnotes', () => { 38 | const contents = ` 39 | # Hello World 40 | 41 | This is a simple footnote[^1]. next footnote[^2]. 42 | 43 | [^1]: meaningful 44 | 45 | [^2]: meaningful 2 46 | 47 | `; 48 | 49 | const expected = ` 50 | # Hello World 51 | 52 | This is a simple footnote[^fn-nth-1]. next footnote[^fn-nth-2]. 53 | 54 | [^fn-nth-1]: meaningful 55 | 56 | [^fn-nth-2]: meaningful 2 57 | 58 | `; 59 | const actual = convertFootnotes(contents); 60 | expect(actual).toEqual(expected); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/tests/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export class Notice { 2 | constructor(message: string) { 3 | console.log(message); 4 | } 5 | } 6 | 7 | export type VaultConfig = { 8 | configDir: string; 9 | }; 10 | 11 | export class TFile { 12 | path: string; 13 | name: string; 14 | constructor(path: string, name: string) { 15 | this.path = path; 16 | this.name = name; 17 | } 18 | } 19 | 20 | export class FileSystemAdapter { 21 | getBasePath(): string { 22 | return '/mock/base/path'; 23 | } 24 | } 25 | 26 | export class Vault { 27 | adapter: FileSystemAdapter; 28 | config: VaultConfig; 29 | 30 | constructor() { 31 | this.adapter = new FileSystemAdapter(); 32 | this.config = { 33 | configDir: '/mock/config', 34 | }; 35 | } 36 | 37 | getMarkdownFiles(): TFile[] { 38 | return []; 39 | } 40 | 41 | copy(file: TFile, newPath: string): Promise { 42 | return Promise.resolve(new TFile(newPath, file.name)); 43 | } 44 | 45 | delete(file: TFile): Promise { 46 | return Promise.resolve(); 47 | } 48 | } 49 | 50 | export class App { 51 | vault: Vault; 52 | fileManager: { 53 | renameFile: (file: TFile, newPath: string) => Promise; 54 | }; 55 | 56 | constructor() { 57 | this.vault = new Vault(); 58 | this.fileManager = { 59 | renameFile: () => Promise.resolve(), 60 | }; 61 | } 62 | } 63 | 64 | export class Plugin { 65 | app: App; 66 | 67 | constructor() { 68 | this.app = new App(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/tests/ConverterChain.test.ts: -------------------------------------------------------------------------------- 1 | import { Contents, Converter } from '../core/Converter'; 2 | import { ConverterChain } from '../core/ConverterChain'; 3 | 4 | class TestRepeatConverter implements Converter { 5 | convert(input: Contents): Contents { 6 | return input + input; 7 | } 8 | } 9 | 10 | class TestUpperConverter implements Converter { 11 | convert(input: Contents): Contents { 12 | return input.toUpperCase(); 13 | } 14 | } 15 | 16 | describe('ConverterChain', () => { 17 | it('should created', () => { 18 | const chain = ConverterChain.create(); 19 | expect(chain).toBeDefined(); 20 | }); 21 | 22 | it('should chaining', () => { 23 | const chain = ConverterChain.create(); 24 | expect(chain.chaining(new TestRepeatConverter())).toBeDefined(); 25 | }); 26 | 27 | it('should converting', () => { 28 | const chain = ConverterChain.create().chaining(new TestRepeatConverter()); 29 | expect(chain.converting('test')).toEqual('testtest'); 30 | }); 31 | 32 | it('should chaining multiple converters', () => { 33 | const chain = ConverterChain.create() 34 | .chaining(new TestRepeatConverter()) 35 | .chaining(new TestUpperConverter()); 36 | expect(chain.converting('test')).toEqual('TESTTEST'); 37 | }); 38 | 39 | it('should chaining multiple converters in reverse order', () => { 40 | const chain = ConverterChain.create() 41 | .chaining(new TestUpperConverter()) 42 | .chaining(new TestRepeatConverter()); 43 | expect(chain.converting('test')).toEqual('TESTTEST'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/tests/platforms/docusaurus/docusaurus.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertContent, 3 | getCurrentDate, 4 | } from '../../../platforms/docusaurus/docusaurus'; 5 | 6 | describe('Docusaurus Conversion', () => { 7 | describe('getCurrentDate', () => { 8 | it('should return date in YYYY-MM-DD format', () => { 9 | const date = getCurrentDate(); 10 | expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); 11 | }); 12 | }); 13 | 14 | describe('convertContent', () => { 15 | it('should convert wiki links', () => { 16 | const input = '[[Some Page]]'; 17 | const result = convertContent(input); 18 | expect(result).toBe('Some Page'); 19 | }); 20 | 21 | it('should convert footnotes', () => { 22 | const input = 'Text[^1]\n\n[^1]: Footnote'; 23 | const result = convertContent(input); 24 | expect(result).toContain('[^fn-nth-1]'); 25 | expect(result).toContain('[^fn-nth-1]: Footnote'); 26 | }); 27 | 28 | it('should convert callouts', () => { 29 | const input = '> [!note] Title\n> Content'; 30 | const result = convertContent(input); 31 | expect(result).toContain(':::note'); 32 | expect(result).toContain(':::'); 33 | }); 34 | 35 | it('should handle multiple conversions together', () => { 36 | const input = 37 | '[[Page]] with [^1]\n\n[^1]: Note\n\n> [!info] Info\n> Text'; 38 | const result = convertContent(input); 39 | expect(result).toBe( 40 | 'Page with [^fn-nth-1]\n\n[^fn-nth-1]: Note\n\n:::info[Info]\n\nText\n\n:::', 41 | ); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/core/types/types.ts: -------------------------------------------------------------------------------- 1 | // Front Matter Types 2 | export type FrontMatter = Record; 3 | 4 | export interface ConversionResult { 5 | frontMatter: FrontMatter; 6 | body: string; 7 | } 8 | 9 | export interface ConversionError { 10 | type: 11 | | 'PARSE_ERROR' 12 | | 'VALIDATION_ERROR' 13 | | 'PROCESS_ERROR' 14 | | 'READ_ERROR' 15 | | 'WRITE_ERROR' 16 | | 'MOVE_ERROR'; 17 | message: string; 18 | } 19 | 20 | // Either Type and Utilities 21 | export interface Left { 22 | readonly _tag: 'Left'; 23 | readonly value: E; 24 | } 25 | 26 | export interface Right { 27 | readonly _tag: 'Right'; 28 | readonly value: A; 29 | } 30 | 31 | export type Either = Left | Right; 32 | 33 | export const left = (e: E): Either => ({ 34 | _tag: 'Left', 35 | value: e, 36 | }); 37 | 38 | export const right = (a: A): Either => ({ 39 | _tag: 'Right', 40 | value: a, 41 | }); 42 | 43 | export const isLeft = (ma: Either): ma is Left => 44 | ma._tag === 'Left'; 45 | 46 | export const isRight = (ma: Either): ma is Right => 47 | ma._tag === 'Right'; 48 | 49 | export const fold = 50 | (onLeft: (e: E) => B, onRight: (a: A) => B) => 51 | (ma: Either): B => 52 | isLeft(ma) ? onLeft(ma.value) : onRight(ma.value); 53 | 54 | export const map = 55 | (f: (a: A) => B) => 56 | (ma: Either): Either => 57 | isLeft(ma) ? ma : right(f(ma.value)); 58 | 59 | export const chain = 60 | (f: (a: A) => Either) => 61 | (ma: Either): Either => 62 | ma._tag === 'Left' ? ma : f(ma.value); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "o2", 3 | "version": "2.4.1", 4 | "description": "This is a plugin to make obsidian markdown syntax compatible with other markdown syntax.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "test": "jest --coverage", 11 | "prettier:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", 12 | "prettier:write": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", 13 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 14 | "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", 15 | "prepare": "husky" 16 | }, 17 | "keywords": [ 18 | "obsidian", 19 | "plugin", 20 | "jekyll" 21 | ], 22 | "author": "haril song", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@js-temporal/polyfill": "^0.5.1", 26 | "@types/jest": "^30.0.0", 27 | "@types/js-yaml": "^4.0.9", 28 | "@types/node": "^24.9.2", 29 | "@typescript-eslint/eslint-plugin": "8.46.2", 30 | "@typescript-eslint/parser": "8.46.2", 31 | "builtin-modules": "5.0.0", 32 | "esbuild": "0.25.11", 33 | "eslint": "^9.34.0", 34 | "eslint-plugin-yml": "^1.19.0", 35 | "husky": "^9.1.7", 36 | "jest": "^30.2.0", 37 | "lint-staged": "^16.2.3", 38 | "obsidian": "^1.10.0", 39 | "prettier": "^3.5.3", 40 | "process": "^0.11.10", 41 | "ts-jest": "^29.4.1", 42 | "tslib": "2.8.1", 43 | "typescript": "5.9.3" 44 | }, 45 | "lint-staged": { 46 | "*.{ts,tsx,js,jsx,json,css,md}": [ 47 | "prettier --write" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/tests/core/converters/WikiLinkConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { WikiLinkConverter } from '../../../core/converters/WikiLinkConverter'; 2 | 3 | const converter = new WikiLinkConverter(); 4 | 5 | describe('WikiLinkConverter', () => { 6 | it('should remove wiki links', () => { 7 | const input = '[[Link]]'; 8 | const expected = 'Link'; 9 | const result = converter.convert(input); 10 | expect(result).toEqual(expected); 11 | }); 12 | 13 | it.each([ 14 | ['[[develop obsidian plugin|O2]]', 'O2'], 15 | ['[[Link|Alias]]', 'Alias'], 16 | ])('should remove wiki links but remain alias only', (input, expected) => { 17 | const result = converter.convert(input); 18 | expect(result).toEqual(expected); 19 | }); 20 | 21 | it('long context', () => { 22 | const input = `# test 23 | ![NOTE] test 24 | [[test]] 25 | 26 | ![[test]] 27 | `; 28 | const result = converter.convert(input); 29 | expect(result).toBe(`# test 30 | ![NOTE] test 31 | test 32 | 33 | ![[test]] 34 | `); 35 | }); 36 | 37 | it('long context 2', () => { 38 | const input = `# test 39 | ![NOTE] test 40 | [[test]] 41 | [[develop obsidian plugin|O2]] and [[Jekyll]] 42 | 43 | ![[test]] 44 | `; 45 | const result = converter.convert(input); 46 | expect(result).toBe(`# test 47 | ![NOTE] test 48 | test 49 | O2 and Jekyll 50 | 51 | ![[test]] 52 | `); 53 | }); 54 | 55 | it('should not match if string starts with !', () => { 56 | const input = '![[tests]]'; 57 | const result = converter.convert(input); 58 | expect(result).toBe('![[tests]]'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/tests/types.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Either, 3 | left, 4 | right, 5 | chain, 6 | isLeft, 7 | isRight, 8 | } from '../core/types/types'; 9 | 10 | describe('Either type utilities', () => { 11 | describe('chain', () => { 12 | const addOne = (n: number): Either => right(n + 1); 13 | const failOnZero = (n: number): Either => 14 | n === 0 ? left('Cannot process zero') : right(n); 15 | 16 | it('should return the passed Left value without invoking the function', () => { 17 | const error = left('error'); 18 | const result = chain(addOne)(error); 19 | expect(isLeft(result)).toBe(true); 20 | expect(result).toEqual(error); 21 | }); 22 | 23 | it('should apply the function to the Right value', () => { 24 | const value = right(1); 25 | const result = chain(addOne)(value); 26 | expect(isRight(result)).toBe(true); 27 | expect(result).toEqual(right(2)); 28 | }); 29 | 30 | it('should handle chaining multiple operations', () => { 31 | const value = right(1); 32 | const result = chain(addOne)(chain(addOne)(value)); 33 | expect(isRight(result)).toBe(true); 34 | expect(result).toEqual(right(3)); 35 | }); 36 | 37 | it('should handle operations that may fail', () => { 38 | // Test successful case 39 | const value = right(1); 40 | const result = chain(failOnZero)(value); 41 | expect(isRight(result)).toBe(true); 42 | expect(result).toEqual(right(1)); 43 | 44 | // Test failure case 45 | const zeroValue = right(0); 46 | const failedResult = chain(failOnZero)(zeroValue); 47 | expect(isLeft(failedResult)).toBe(true); 48 | expect(failedResult).toEqual(left('Cannot process zero')); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | import eslintPluginYml from 'eslint-plugin-yml'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }); 17 | 18 | export default [ 19 | { 20 | ignores: [ 21 | '**/node_modules/**', 22 | '**/dist/**', 23 | '**/coverage/**', 24 | '**/tests/**', 25 | 'src/tests/**', 26 | '**/*.test.ts', 27 | '**/*.spec.ts', 28 | '**/test/**', 29 | '**/test-utils/**', 30 | '**/test-helpers/**' 31 | ], 32 | }, 33 | { 34 | files: ['**/*.ts', '**/*.tsx', '**/*.json'], 35 | }, 36 | ...eslintPluginYml.configs['flat/recommended'], 37 | ...compat.extends( 38 | 'eslint:recommended', 39 | 'plugin:@typescript-eslint/eslint-recommended', 40 | 'plugin:@typescript-eslint/recommended', 41 | ), 42 | { 43 | plugins: { 44 | '@typescript-eslint': typescriptEslint, 45 | }, 46 | 47 | languageOptions: { 48 | globals: { 49 | ...globals.node, 50 | }, 51 | 52 | parser: tsParser, 53 | ecmaVersion: 2018, 54 | sourceType: 'module', 55 | 56 | parserOptions: { 57 | project: './tsconfig.json', 58 | }, 59 | }, 60 | 61 | rules: { 62 | 'no-unused-vars': 'off', 63 | 64 | '@typescript-eslint/no-unused-vars': [ 65 | 'error', 66 | { 67 | args: 'none', 68 | }, 69 | ], 70 | 71 | '@typescript-eslint/ban-ts-comment': 'off', 72 | 'no-prototype-builtins': 'off', 73 | '@typescript-eslint/no-empty-function': 'off', 74 | semi: ['error', 'always'], 75 | }, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /src/core/validation.ts: -------------------------------------------------------------------------------- 1 | import O2Plugin from '../main'; 2 | import { Notice } from 'obsidian'; 3 | 4 | export default async (plugin: O2Plugin) => { 5 | const adapter = plugin.app.vault.adapter; 6 | if (!(await adapter.exists(plugin.obsidianPathSettings.attachmentsFolder))) { 7 | if (plugin.obsidianPathSettings.isAutoCreateFolder) { 8 | new Notice( 9 | `Auto create attachments folder: ${plugin.obsidianPathSettings.attachmentsFolder}.`, 10 | 5000, 11 | ); 12 | await adapter.mkdir(plugin.obsidianPathSettings.attachmentsFolder); 13 | } else { 14 | new Notice( 15 | `Attachments folder ${plugin.obsidianPathSettings.attachmentsFolder} does not exist.`, 16 | 5000, 17 | ); 18 | throw new Error( 19 | `Attachments folder ${plugin.obsidianPathSettings.attachmentsFolder} does not exist.`, 20 | ); 21 | } 22 | } 23 | if (!(await adapter.exists(plugin.obsidianPathSettings.readyFolder))) { 24 | if (plugin.obsidianPathSettings.isAutoCreateFolder) { 25 | new Notice( 26 | `Auto create ready folder: ${plugin.obsidianPathSettings.readyFolder}.`, 27 | 5000, 28 | ); 29 | await adapter.mkdir(plugin.obsidianPathSettings.readyFolder); 30 | } else { 31 | new Notice( 32 | `Ready folder ${plugin.obsidianPathSettings.readyFolder} does not exist.`, 33 | 5000, 34 | ); 35 | throw new Error( 36 | `Ready folder ${plugin.obsidianPathSettings.readyFolder} does not exist.`, 37 | ); 38 | } 39 | } 40 | if (!(await adapter.exists(plugin.obsidianPathSettings.archiveFolder))) { 41 | if (plugin.obsidianPathSettings.isAutoCreateFolder) { 42 | new Notice( 43 | `Auto create backup folder: ${plugin.obsidianPathSettings.archiveFolder}.`, 44 | 5000, 45 | ); 46 | await adapter.mkdir(plugin.obsidianPathSettings.archiveFolder); 47 | } else { 48 | new Notice( 49 | `Backup folder ${plugin.obsidianPathSettings.archiveFolder} does not exist.`, 50 | 5000, 51 | ); 52 | throw new Error( 53 | `Backup folder ${plugin.obsidianPathSettings.archiveFolder} does not exist.`, 54 | ); 55 | } 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/platforms/jekyll/settings/JekyllSettings.ts: -------------------------------------------------------------------------------- 1 | import { O2PluginSettings } from '../../../settings'; 2 | import { convertFileName } from '../../../core/converters/FilenameConverter'; 3 | 4 | export default class JekyllSettings implements O2PluginSettings { 5 | private _jekyllPath: string; 6 | private _jekyllRelativeResourcePath: string; 7 | pathReplacer( 8 | year: string, 9 | month: string, 10 | day: string, 11 | title: string, 12 | ): string { 13 | title = convertFileName(title); 14 | return `${year}-${month}-${day}-${title}.md`; 15 | } 16 | 17 | // FIXME: abstraction 18 | private _isEnableBanner: boolean; 19 | private _isEnableCurlyBraceConvertMode: boolean; 20 | private _isEnableUpdateFrontmatterTimeOnEdit: boolean; 21 | private _isEnableRelativeUrl: boolean; 22 | 23 | constructor() { 24 | this._jekyllPath = ''; 25 | this._jekyllRelativeResourcePath = 'assets/img'; 26 | this._isEnableRelativeUrl = false; 27 | } 28 | 29 | get jekyllPath(): string { 30 | return this._jekyllPath; 31 | } 32 | 33 | set jekyllPath(value: string) { 34 | this._jekyllPath = value; 35 | } 36 | 37 | get jekyllRelativeResourcePath(): string { 38 | return this._jekyllRelativeResourcePath; 39 | } 40 | 41 | set jekyllRelativeResourcePath(value: string) { 42 | this._jekyllRelativeResourcePath = value; 43 | } 44 | 45 | get isEnableBanner(): boolean { 46 | return this._isEnableBanner; 47 | } 48 | 49 | set isEnableBanner(value: boolean) { 50 | this._isEnableBanner = value; 51 | } 52 | 53 | get isEnableCurlyBraceConvertMode(): boolean { 54 | return this._isEnableCurlyBraceConvertMode; 55 | } 56 | 57 | set isEnableCurlyBraceConvertMode(value: boolean) { 58 | this._isEnableCurlyBraceConvertMode = value; 59 | } 60 | 61 | get isEnableUpdateFrontmatterTimeOnEdit(): boolean { 62 | return this._isEnableUpdateFrontmatterTimeOnEdit; 63 | } 64 | 65 | set isEnableUpdateFrontmatterTimeOnEdit(value: boolean) { 66 | this._isEnableUpdateFrontmatterTimeOnEdit = value; 67 | } 68 | 69 | get isEnableRelativeUrl(): boolean { 70 | return this._isEnableRelativeUrl; 71 | } 72 | 73 | set isEnableRelativeUrl(value: boolean) { 74 | this._isEnableRelativeUrl = value; 75 | } 76 | 77 | targetPath(): string { 78 | return `${this._jekyllPath}/_posts`; 79 | } 80 | 81 | targetSubPath(folder: string) { 82 | return `${this._jekyllPath}/${folder}`; 83 | } 84 | 85 | resourcePath(): string { 86 | return `${this._jekyllPath}/${this._jekyllRelativeResourcePath}`; 87 | } 88 | 89 | afterPropertiesSet(): boolean { 90 | return this._jekyllPath !== ''; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/core/fp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functional programming utilities 3 | */ 4 | 5 | /** 6 | * Pipe function that takes a value and a series of functions, 7 | * applying each function to the result of the previous function 8 | */ 9 | export function pipe(a: A): A; 10 | export function pipe(a: A, ab: (a: A) => B): B; 11 | export function pipe(a: A, ab: (a: A) => B, bc: (b: B) => C): C; 12 | export function pipe( 13 | a: A, 14 | ab: (a: A) => B, 15 | bc: (b: B) => C, 16 | cd: (c: C) => D, 17 | ): D; 18 | export function pipe( 19 | a: A, 20 | ab: (a: A) => B, 21 | bc: (b: B) => C, 22 | cd: (c: C) => D, 23 | de: (d: D) => E, 24 | ): E; 25 | export function pipe( 26 | a: A, 27 | ab: (a: A) => B, 28 | bc: (b: B) => C, 29 | cd: (c: C) => D, 30 | de: (d: D) => E, 31 | ef: (e: E) => F, 32 | ): F; 33 | export function pipe( 34 | a: unknown, 35 | ...fns: Array<(a: unknown) => unknown> 36 | ): unknown { 37 | return fns.reduce((acc, fn) => fn(acc), a); 38 | } 39 | 40 | /** 41 | * Function composition from right to left 42 | */ 43 | export const compose = (...fns: Array<(a: A) => A>): ((a: A) => A) => 44 | fns.reduce((f, g) => x => f(g(x))); 45 | 46 | /** 47 | * Creates a curried version of a function 48 | */ 49 | export function curry( 50 | fn: (a: A, b: B, c: C) => R, 51 | ): (a: A) => (b: B) => (c: C) => R; 52 | export function curry(fn: (a: A, b: B) => R): (a: A) => (b: B) => R; 53 | export function curry(fn: (a: A) => R): (a: A) => R; 54 | export function curry(fn: (...args: unknown[]) => unknown) { 55 | return function curried(...args: unknown[]) { 56 | if (args.length >= fn.length) { 57 | return fn(...args); 58 | } 59 | return (...moreArgs: unknown[]) => curried(...args, ...moreArgs); 60 | }; 61 | } 62 | 63 | /** 64 | * Maps a function over an array 65 | */ 66 | export const map = 67 | (fn: (a: A) => B) => 68 | (fa: Array): Array => 69 | fa.map(fn); 70 | 71 | /** 72 | * Filters an array based on a predicate 73 | */ 74 | export const filter = 75 | (predicate: (a: A) => boolean) => 76 | (fa: Array): Array => 77 | fa.filter(predicate); 78 | 79 | /** 80 | * Reduces an array using an accumulator function and initial value 81 | */ 82 | export const reduce = 83 | (fn: (b: B, a: A) => B, initial: B) => 84 | (fa: Array): B => 85 | fa.reduce(fn, initial); 86 | 87 | /** 88 | * Identity function 89 | */ 90 | export const identity = (a: A): A => a; 91 | 92 | /** 93 | * Creates a constant function that always returns the same value 94 | */ 95 | export const constant = 96 | (a: A) => 97 | () => 98 | a; 99 | -------------------------------------------------------------------------------- /src/core/converters/CalloutConverter.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianRegex } from '../ObsidianRegex'; 2 | import { Converter } from '../Converter'; 3 | 4 | export class CalloutConverter implements Converter { 5 | convert(input: string): string { 6 | return convertCalloutSyntaxToChirpy(input); 7 | } 8 | } 9 | 10 | const jekyllReplacer = (match: string, p1: string, p2: string, p3: string) => 11 | `${p3}\n{: .prompt-${replaceKeyword(p1)}}`; 12 | 13 | const convertCalloutSyntaxToChirpy = (content: string) => 14 | content.replace(ObsidianRegex.CALLOUT, jekyllReplacer); 15 | 16 | const jekyllCalloutMap = new Map(); 17 | jekyllCalloutMap.set('note', 'info'); 18 | jekyllCalloutMap.set('info', 'info'); 19 | jekyllCalloutMap.set('todo', 'info'); 20 | jekyllCalloutMap.set('example', 'info'); 21 | jekyllCalloutMap.set('quote', 'info'); 22 | jekyllCalloutMap.set('cite', 'info'); 23 | jekyllCalloutMap.set('success', 'info'); 24 | jekyllCalloutMap.set('done', 'info'); 25 | jekyllCalloutMap.set('check', 'info'); 26 | jekyllCalloutMap.set('tip', 'tip'); 27 | jekyllCalloutMap.set('hint', 'tip'); 28 | jekyllCalloutMap.set('important', 'tip'); 29 | jekyllCalloutMap.set('question', 'tip'); 30 | jekyllCalloutMap.set('help', 'tip'); 31 | jekyllCalloutMap.set('faq', 'tip'); 32 | jekyllCalloutMap.set('failure', 'danger'); 33 | jekyllCalloutMap.set('fail', 'danger'); 34 | jekyllCalloutMap.set('missing', 'danger'); 35 | jekyllCalloutMap.set('error', 'danger'); 36 | jekyllCalloutMap.set('danger', 'danger'); 37 | jekyllCalloutMap.set('bug', 'danger'); 38 | jekyllCalloutMap.set('warning', 'warning'); 39 | jekyllCalloutMap.set('caution', 'warning'); 40 | jekyllCalloutMap.set('attention', 'warning'); 41 | 42 | function replaceKeyword(target: string) { 43 | return jekyllCalloutMap.get(target.toLowerCase()) || 'info'; 44 | } 45 | 46 | const docusaurusReplacer = ( 47 | match: string, 48 | p1: string, 49 | p2: string, 50 | p3: string, 51 | ) => { 52 | const title = p2 ? `[${p2.trim()}]` : ''; 53 | return `:::${replaceDocusaurusKeyword(p1)}${title}\n\n${replaceDocusaurusContents(p3)}\n\n:::`; 54 | }; 55 | 56 | export const convertDocusaurusCallout = (input: string) => 57 | input.replace(ObsidianRegex.CALLOUT, docusaurusReplacer); 58 | 59 | const replaceDocusaurusKeyword = (target: string) => 60 | docusaurusCalloutMap[target.toLowerCase()] || 'note'; 61 | 62 | const docusaurusCalloutMap: { [key: string]: string } = { 63 | note: 'note', 64 | info: 'info', 65 | todo: 'note', 66 | example: 'note', 67 | quote: 'note', 68 | cite: 'note', 69 | success: 'note', 70 | done: 'note', 71 | check: 'note', 72 | tip: 'tip', 73 | hint: 'tip', 74 | important: 'tip', 75 | question: 'tip', 76 | help: 'tip', 77 | faq: 'tip', 78 | failure: 'danger', 79 | fail: 'danger', 80 | missing: 'danger', 81 | error: 'danger', 82 | danger: 'danger', 83 | bug: 'danger', 84 | warning: 'warning', 85 | caution: 'warning', 86 | attention: 'warning', 87 | }; 88 | 89 | const replaceDocusaurusContents = (contents: string) => 90 | contents.replace(/^> /, ''); 91 | -------------------------------------------------------------------------------- /src/platforms/docusaurus/DateExtractionPattern.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SINGLE: YYYY-MM-DD-my-blog-post-title.md 3 | * MDX: YYYY-MM-DD-my-blog-post-title.mdx 4 | * Single folder + index.md: YYYY-MM-DD-my-blog-post-title/index.md 5 | * Folder named by date: YYYY-MM-DD/my-blog-post-title.md 6 | * Nested folders by date: YYYY/MM/DD/my-blog-post-title.md 7 | * Partially nested folders by date: YYYY/MM-DD/my-blog-post-title.md 8 | * Nested folders + index.md: YYYY/MM/DD/my-blog-post-title/index.md 9 | * Date in the middle of path: category/YYYY/MM-DD-my-blog-post-title.md 10 | */ 11 | export const DateExtractionPattern: Record< 12 | string, 13 | DateExtractionPatternInterface 14 | > = { 15 | // default pattern 16 | SINGLE: { 17 | pattern: 'YYYY-MM-DD-my-blog-post-title.md', 18 | regexp: /(\d{4})-(\d{2})-(\d{2})-(.*)\.md/, 19 | replacer: (year, month, day, title) => { 20 | return `${year}-${month}-${day}-${title}.md`; 21 | }, 22 | }, 23 | MDX: { 24 | pattern: 'YYYY-MM-DD-my-blog-post-title.mdx', 25 | regexp: /(\d{4})-(\d{2})-(\d{2})-(.*)\.mdx/, 26 | replacer: (year, month, day, title) => { 27 | return `${year}-${month}-${day}-${title}.mdx`; 28 | }, 29 | }, 30 | SINGLE_FOLDER_INDEX: { 31 | pattern: 'YYYY-MM-DD-my-blog-post-title/index.md', 32 | regexp: /(\d{4})-(\d{2})-(\d{2})-(.*)\/index\.md/, 33 | replacer: (year, month, day, title) => { 34 | return `${year}-${month}-${day}-${title}/index.md`; 35 | }, 36 | }, 37 | FOLDER_NAMED_BY_DATE: { 38 | pattern: 'YYYY-MM-DD/my-blog-post-title.md', 39 | regexp: /(\d{4})-(\d{2})-(\d{2})\/(.*)\.md/, 40 | replacer: (year, month, day, title) => { 41 | return `${year}-${month}-${day}/${title}.md`; 42 | }, 43 | }, 44 | NESTED_FOLDERS_BY_DATE: { 45 | pattern: 'YYYY/MM/DD/my-blog-post-title.md', 46 | regexp: /(\d{4})\/(\d{2})\/(\d{2})\/(.*)\.md/, 47 | replacer: (year, month, day, title) => { 48 | return `${year}/${month}/${day}/${title}.md`; 49 | }, 50 | }, 51 | PARTIALLY_NESTED_FOLDERS_BY_DATE: { 52 | pattern: 'YYYY/MM-DD/my-blog-post-title.md', 53 | regexp: /(\d{4})\/(\d{2})-(\d{2})\/(.*)\.md/, 54 | replacer: (year, month, day, title) => { 55 | return `${year}/${month}-${day}/${title}.md`; 56 | }, 57 | }, 58 | NESTED_FOLDERS_INDEX: { 59 | pattern: 'YYYY/MM/DD/my-blog-post-title/index.md', 60 | regexp: /(\d{4})\/(\d{2})\/(\d{2})\/(.*)\/index\.md/, 61 | replacer: (year, month, day, title) => { 62 | return `${year}/${month}/${day}/${title}/index.md`; 63 | }, 64 | }, 65 | // FIXME: 66 | DATE_IN_MIDDLE_OF_PATH: { 67 | pattern: 'category/YYYY/MM-DD-my-blog-post-title.md', 68 | regexp: /category\/(\d{4})\/(\d{2})-(\d{2})-(.*)\.md/, 69 | replacer: (year, month, day, title) => { 70 | return `category/${year}/${month}-${day}-${title}.md`; 71 | }, 72 | }, 73 | }; 74 | 75 | export interface DateExtractionPatternInterface { 76 | pattern: string; 77 | regexp: RegExp; 78 | replacer: (year: string, month: string, day: string, title: string) => string; 79 | } 80 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin } from 'obsidian'; 2 | import { O2SettingTab, ObsidianPathSettings } from './settings'; 3 | import JekyllSettings from './platforms/jekyll/settings/JekyllSettings'; 4 | import DocusaurusSettings from './platforms/docusaurus/settings/DocusaurusSettings'; 5 | import { convertToChirpy } from './platforms/jekyll/chirpy'; 6 | import { convertToDocusaurus } from './platforms/docusaurus/docusaurus'; 7 | import { archiving, cleanUp } from './core/utils/utils'; 8 | import validateSettings from './core/validation'; 9 | 10 | interface O2PluginSettings { 11 | obsidianPathSettings: ObsidianPathSettings; 12 | jekyll: JekyllSettings; 13 | docusaurus: DocusaurusSettings; 14 | } 15 | 16 | const DEFAULT_SETTINGS: O2PluginSettings = { 17 | obsidianPathSettings: new ObsidianPathSettings(), 18 | jekyll: new JekyllSettings(), 19 | docusaurus: new DocusaurusSettings(), 20 | }; 21 | 22 | export default class O2Plugin extends Plugin { 23 | obsidianPathSettings: ObsidianPathSettings; 24 | jekyll: JekyllSettings; 25 | docusaurus: DocusaurusSettings; 26 | 27 | async onload() { 28 | await this.loadSettings(); 29 | 30 | this.addCommand({ 31 | id: 'grammar-transformation', 32 | name: 'Grammar Transformation', 33 | checkCallback: (checking: boolean) => { 34 | if ( 35 | this.jekyll.afterPropertiesSet() || 36 | this.docusaurus.afterPropertiesSet() 37 | ) { 38 | if (checking) { 39 | return true; 40 | } 41 | o2ConversionCommand(this) 42 | .then(() => new Notice('Converted to successfully.', 5000)) 43 | .then(() => { 44 | if (this.obsidianPathSettings.isAutoArchive) { 45 | archiving(this); 46 | } 47 | }); 48 | } 49 | }, 50 | }); 51 | 52 | this.addSettingTab(new O2SettingTab(this.app, this)); 53 | } 54 | 55 | onunload() {} 56 | 57 | async loadSettings() { 58 | const data = (await this.loadData()) as O2PluginSettings; 59 | 60 | // Merge saved settings with defaults 61 | this.obsidianPathSettings = Object.assign( 62 | new ObsidianPathSettings(), 63 | DEFAULT_SETTINGS.obsidianPathSettings, 64 | data?.obsidianPathSettings, 65 | ); 66 | this.jekyll = Object.assign( 67 | new JekyllSettings(), 68 | DEFAULT_SETTINGS.jekyll, 69 | data?.jekyll, 70 | ); 71 | this.docusaurus = Object.assign( 72 | new DocusaurusSettings(), 73 | DEFAULT_SETTINGS.docusaurus, 74 | data?.docusaurus, 75 | ); 76 | } 77 | 78 | async saveSettings() { 79 | await this.saveData({ 80 | obsidianPathSettings: this.obsidianPathSettings, 81 | jekyll: this.jekyll, 82 | docusaurus: this.docusaurus, 83 | }); 84 | } 85 | } 86 | 87 | const o2ConversionCommand = async (plugin: O2Plugin) => { 88 | await validateSettings(plugin); 89 | if (plugin.jekyll.afterPropertiesSet()) { 90 | await convertToChirpy(plugin).finally(() => cleanUp(plugin)); 91 | } 92 | 93 | if (plugin.docusaurus.afterPropertiesSet()) { 94 | await convertToDocusaurus(plugin).finally(() => cleanUp(plugin)); 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # O2 2 | 3 | 4 | [![CI](https://github.com/songkg7/o2/actions/workflows/ci.yml/badge.svg)](https://github.com/songkg7/o2/actions/workflows/node.js.yml) 5 | [![CodeFactor](https://www.codefactor.io/repository/github/songkg7/o2/badge)](https://www.codefactor.io/repository/github/songkg7/o2) 6 | [![Super-Linter](https://github.com/songkg7/o2/actions/workflows/linter.yml/badge.svg)](https://github.com/marketplace/actions/super-linter) 7 | [![codecov](https://codecov.io/gh/songkg7/o2/branch/main/graph/badge.svg?token=AYQGNW0SWR)](https://codecov.io/gh/songkg7/o2) 8 | [![Obsidian downloads](https://img.shields.io/badge/dynamic/json?logo=Obsidian&color=%238b6cef&label=downloads&query=o2.downloads&url=https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugin-stats.json)][community-plugin] 9 | 10 | [community-plugin]: https://obsidian.md/plugins?id=o2 11 | 12 | > [!WARNING] 13 | > Development of new features for this project has been discontinued. This decision was made because I am now envisioning a new tool that will not be tied to the Obsidian platform. 14 | 15 | Write once, convert to multiple platforms. 16 | 17 | O2 is a tool that converts your Obsidian Markdown files to other Markdown platforms such as Jekyll or Docusaurus. 18 | 19 | ## Prerequisites 20 | 21 | ### Structure of your vault 22 | 23 | You should have a folder structure like this. (of course, you can change the folder names in settings) 24 | 25 | ```text 26 | Your vault 27 | ├── ready (where the notes you want to convert are placed) 28 | ├── archive (where the original notes before converting are placed) 29 | └── attachments (where the attachments are placed) 30 | ``` 31 | 32 | Other Folders will be ignored. 33 | 34 | ## How to use 35 | 36 | If you want to convert your notes, you should move them to the `ready` Folder. 37 | 38 | then, Execute the command `O2: Grammar Transformation` via obsidian's `cmd + p` shortcut. 39 | 40 | ## Supported platforms 41 | 42 | - Jekyll Chirpy 43 | - Docusaurus 44 | 45 | Please visit the [documentation](https://haril.dev/en/docs/category/o2) for more information. 46 | 47 | ## Plugins that work well together 48 | 49 | - [imgur](https://github.com/gavvvr/obsidian-imgur-plugin): Recommanded 50 | - [Update frontmatter time on edit](https://github.com/beaussan/update-time-on-edit-obsidian) 51 | 52 | ## Contributing 53 | 54 | Pull requests are always welcome! For major changes, please open an issue (or discussion) first to discuss what you would like to change. 55 | like to 56 | change. 57 | 58 | For the detailed information about building and developing O2, 59 | please visit [Obsidian Docs](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin). 60 | 61 | Thanks to all [contributors](https://github.com/songkg7/o2/graphs/contributors) 62 | 63 | [![Contributors](https://contrib.rocks/image?repo=songkg7/o2)](https://github.com/songkg7/o2/graphs/contributors) 64 | 65 | ## Articles 66 | 67 | - [O2 plugin 개발하기](https://haril.dev/blog/2023/02/22/develop-obsidian-plugin) 68 | - [Obsidian 플러그인 오픈소스 기여하기](https://l2hyunn.github.io/posts/Obsidian-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC%ED%95%98%EA%B8%B0/) 69 | - [How to use the O2 plugin for Obsidian](https://pedrobiqua.github.io/posts/How-to-use-the-O2-plugin-for-Obsidian/) 70 | 71 | Welcome to write articles about O2! 72 | 73 | ## License 74 | 75 | This project is published under [MIT](https://choosealicense.com/licenses/mit/) license. 76 | 77 | --- 78 | 79 | If you ever want to buy me a coffee, don't hesitate. 80 | 81 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/V7V8KX38Q) 82 | -------------------------------------------------------------------------------- /src/core/converters/ResourceLinkConverter.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { ObsidianRegex } from '../ObsidianRegex'; 3 | import { Notice } from 'obsidian'; 4 | import { Converter } from '../Converter'; 5 | import { removeTempPrefix } from './FilenameConverter'; 6 | 7 | export class ResourceLinkConverter implements Converter { 8 | private readonly fileName: string; 9 | private readonly resourcePath: string; 10 | private readonly absolutePath: string; 11 | private readonly attachmentsFolder: string; 12 | private readonly relativeResourcePath: string; 13 | private readonly liquidFilterOptions: { useRelativeUrl: boolean }; 14 | 15 | constructor( 16 | fileName: string, 17 | resourcePath: string, 18 | absolutePath: string, 19 | attachmentsFolder: string, 20 | relativeResourcePath: string, 21 | liquidFilterOptions?: { useRelativeUrl: boolean }, 22 | ) { 23 | this.fileName = fileName; 24 | this.resourcePath = resourcePath; 25 | this.absolutePath = absolutePath; 26 | this.attachmentsFolder = attachmentsFolder; 27 | this.relativeResourcePath = relativeResourcePath; 28 | this.liquidFilterOptions = liquidFilterOptions ?? { useRelativeUrl: false }; 29 | } 30 | 31 | convert(input: string): string { 32 | const sanitizedFileName = removeTempPrefix(this.fileName); 33 | const resourcePath = `${this.resourcePath}/${sanitizedFileName}`; 34 | const resourceNames = extractResourceNames(input); 35 | if (!(resourceNames === undefined || resourceNames.length === 0)) { 36 | fs.mkdirSync(resourcePath, { recursive: true }); 37 | } 38 | resourceNames?.forEach(resourceName => { 39 | fs.copyFile( 40 | `${this.absolutePath}/${this.attachmentsFolder}/${resourceName}`, 41 | `${resourcePath}/${resourceName.replace(/\s/g, '-')}`, 42 | err => { 43 | if (err) { 44 | // ignore error 45 | console.error(err); 46 | new Notice(err.message); 47 | } 48 | }, 49 | ); 50 | }); 51 | 52 | const replacer = ( 53 | match: string, 54 | contents: string, 55 | suffix: string, 56 | width: string | undefined, 57 | height: string | undefined, 58 | space: string | undefined, 59 | caption: string | undefined, 60 | ) => { 61 | const imagePath = `/${this.relativeResourcePath}/${sanitizedFileName}/${contents.replace(/\s/g, '-')}.${suffix}`; 62 | const imageUrl = this.liquidFilterOptions.useRelativeUrl 63 | ? `{{ "${imagePath}" | relative_url }}` 64 | : imagePath; 65 | return ( 66 | `![image](${imageUrl})` + 67 | `${convertImageSize(width, height)}` + 68 | `${convertImageCaption(caption)}` 69 | ); 70 | }; 71 | 72 | return input.replace(ObsidianRegex.ATTACHMENT_LINK, replacer); 73 | } 74 | } 75 | 76 | export function extractResourceNames(content: string) { 77 | const result = content.match(ObsidianRegex.ATTACHMENT_LINK); 78 | if (result === null) { 79 | return undefined; 80 | } 81 | return result.map(imageLink => 82 | imageLink.replace(ObsidianRegex.ATTACHMENT_LINK, '$1.$2'), 83 | ); 84 | } 85 | 86 | function convertImageSize( 87 | width: string | undefined, 88 | height: string | undefined, 89 | ) { 90 | if (width === undefined || width.length === 0) { 91 | return ''; 92 | } 93 | if (height === undefined || height.length === 0) { 94 | return `{: width="${width}" }`; 95 | } 96 | return `{: width="${width}" height="${height}" }`; 97 | } 98 | 99 | function convertImageCaption(caption: string | undefined) { 100 | if (caption === undefined || caption.length === 0) { 101 | return ''; 102 | } 103 | return `\n${caption}`; 104 | } 105 | -------------------------------------------------------------------------------- /src/platforms/jekyll/chirpy.ts: -------------------------------------------------------------------------------- 1 | import O2Plugin from '../../main'; 2 | import { Notice } from 'obsidian'; 3 | import { WikiLinkConverter } from '../../core/converters/WikiLinkConverter'; 4 | import { ResourceLinkConverter } from '../../core/converters/ResourceLinkConverter'; 5 | import JekyllSettings from './settings/JekyllSettings'; 6 | import { CalloutConverter } from '../../core/converters/CalloutConverter'; 7 | import { convertFrontMatter } from '../../core/converters/FrontMatterConverter'; 8 | import { 9 | copyMarkdownFile, 10 | moveFiles, 11 | vaultAbsolutePath, 12 | } from '../../core/utils/utils'; 13 | import { FootnotesConverter } from '../../core/converters/FootnotesConverter'; 14 | import { ConverterChain } from '../../core/ConverterChain'; 15 | import { CommentsConverter } from '../../core/converters/CommentsConverter'; 16 | import { EmbedsConverter } from '../../core/converters/EmbedsConverter'; 17 | import { CurlyBraceConverter } from '../../core/converters/CurlyBraceConverter'; 18 | import { convertFileName } from '../../core/converters/FilenameConverter'; 19 | import { isLeft, isRight } from '../../core/types/types'; 20 | 21 | interface LiquidFilterOptions { 22 | useRelativeUrl: boolean; 23 | } 24 | 25 | export async function convertToChirpy(plugin: O2Plugin) { 26 | const settings = plugin.jekyll as JekyllSettings; 27 | try { 28 | const markdownFiles = await copyMarkdownFile(plugin); 29 | for (const file of markdownFiles) { 30 | const fileName = convertFileName(file.name); 31 | const fileContent = await plugin.app.vault.read(file); 32 | 33 | const frontMatterResult = await convertFrontMatter(fileContent); 34 | 35 | if (isLeft(frontMatterResult)) { 36 | console.error( 37 | 'Front matter conversion failed:', 38 | frontMatterResult.value, 39 | ); 40 | continue; 41 | } 42 | 43 | if (!isRight(frontMatterResult)) { 44 | console.error('Unexpected front matter conversion result'); 45 | continue; 46 | } 47 | 48 | const resourceLinkConverter = new ResourceLinkConverter( 49 | fileName, 50 | settings.resourcePath(), 51 | vaultAbsolutePath(plugin), 52 | plugin.obsidianPathSettings.attachmentsFolder, 53 | settings.jekyllRelativeResourcePath, 54 | { useRelativeUrl: settings.isEnableRelativeUrl } as LiquidFilterOptions, 55 | ); 56 | const curlyBraceConverter = new CurlyBraceConverter( 57 | settings.isEnableCurlyBraceConvertMode, 58 | ); 59 | const result = ConverterChain.create() 60 | .chaining(resourceLinkConverter) 61 | .chaining(curlyBraceConverter) 62 | .chaining(new WikiLinkConverter()) 63 | .chaining(new CalloutConverter()) 64 | .chaining(new FootnotesConverter()) 65 | .chaining(new CommentsConverter()) 66 | .chaining(new EmbedsConverter()) 67 | .converting(frontMatterResult.value); 68 | 69 | await plugin.app.vault.modify(file, result); 70 | 71 | const path: string = file.path; 72 | const directory = path.substring(0, path.lastIndexOf('/')); 73 | const folder = directory.substring(directory.lastIndexOf('/') + 1); 74 | 75 | if (folder !== plugin.obsidianPathSettings.readyFolder) { 76 | await moveFiles( 77 | `${vaultAbsolutePath(plugin)}/${plugin.obsidianPathSettings.readyFolder}/${folder}`, 78 | settings.targetSubPath(folder), 79 | settings.pathReplacer, 80 | ).then(() => new Notice('Moved files to Chirpy successfully.', 5000)); 81 | } else { 82 | await moveFiles( 83 | `${vaultAbsolutePath(plugin)}/${plugin.obsidianPathSettings.readyFolder}`, 84 | settings.targetPath(), 85 | settings.pathReplacer, 86 | ).then(() => new Notice('Moved files to Chirpy successfully.', 5000)); 87 | } 88 | } 89 | } catch (e) { 90 | console.error(e); 91 | new Notice('Chirpy conversion failed.'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/tests/fp.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pipe, 3 | compose, 4 | curry, 5 | map, 6 | filter, 7 | reduce, 8 | identity, 9 | constant, 10 | } from '../core/fp'; 11 | 12 | describe('Functional Programming Utilities', () => { 13 | describe('pipe', () => { 14 | it('should pipe a single value through multiple functions', () => { 15 | const add2 = (x: number) => x + 2; 16 | const multiply3 = (x: number) => x * 3; 17 | const toString = (x: number) => x.toString(); 18 | 19 | const result = pipe(5, add2, multiply3, toString); 20 | expect(result).toBe('21'); 21 | }); 22 | 23 | it('should handle a single function', () => { 24 | const add2 = (x: number) => x + 2; 25 | const result = pipe(5, add2); 26 | expect(result).toBe(7); 27 | }); 28 | 29 | it('should handle no functions', () => { 30 | const result = pipe(5); 31 | expect(result).toBe(5); 32 | }); 33 | }); 34 | 35 | describe('compose', () => { 36 | it('should compose multiple functions from right to left', () => { 37 | const add2 = (x: number) => x + 2; 38 | const multiply3 = (x: number) => x * 3; 39 | const composed = compose(add2, multiply3); 40 | expect(composed(5)).toBe(17); // (5 * 3) + 2 41 | }); 42 | 43 | it('should handle a single function', () => { 44 | const add2 = (x: number) => x + 2; 45 | const composed = compose(add2); 46 | expect(composed(5)).toBe(7); 47 | }); 48 | }); 49 | 50 | describe('curry', () => { 51 | it('should curry a function with multiple arguments', () => { 52 | const add = (a: number, b: number, c: number) => a + b + c; 53 | const curriedAdd = curry(add); 54 | expect(curriedAdd(1)(2)(3)).toBe(6); 55 | }); 56 | 57 | it('should handle partial application', () => { 58 | const add = (a: number, b: number) => a + b; 59 | const curriedAdd = curry(add); 60 | const add5 = curriedAdd(5); 61 | expect(add5(3)).toBe(8); 62 | }); 63 | 64 | it('should handle single argument functions', () => { 65 | const double = (x: number) => x * 2; 66 | const curriedDouble = curry(double); 67 | expect(curriedDouble(5)).toBe(10); 68 | }); 69 | }); 70 | 71 | describe('map', () => { 72 | it('should map a function over an array', () => { 73 | const double = (x: number) => x * 2; 74 | const numbers = [1, 2, 3, 4]; 75 | const result = map(double)(numbers); 76 | expect(result).toEqual([2, 4, 6, 8]); 77 | }); 78 | 79 | it('should handle empty arrays', () => { 80 | const double = (x: number) => x * 2; 81 | const result = map(double)([]); 82 | expect(result).toEqual([]); 83 | }); 84 | }); 85 | 86 | describe('filter', () => { 87 | it('should filter array elements based on predicate', () => { 88 | const isEven = (x: number) => x % 2 === 0; 89 | const numbers = [1, 2, 3, 4, 5, 6]; 90 | const result = filter(isEven)(numbers); 91 | expect(result).toEqual([2, 4, 6]); 92 | }); 93 | 94 | it('should handle empty arrays', () => { 95 | const isEven = (x: number) => x % 2 === 0; 96 | const result = filter(isEven)([]); 97 | expect(result).toEqual([]); 98 | }); 99 | }); 100 | 101 | describe('reduce', () => { 102 | it('should reduce array using accumulator function', () => { 103 | const sum = (acc: number, x: number) => acc + x; 104 | const numbers = [1, 2, 3, 4]; 105 | const result = reduce(sum, 0)(numbers); 106 | expect(result).toBe(10); 107 | }); 108 | 109 | it('should handle empty arrays', () => { 110 | const sum = (acc: number, x: number) => acc + x; 111 | const result = reduce(sum, 0)([]); 112 | expect(result).toBe(0); 113 | }); 114 | }); 115 | 116 | describe('identity', () => { 117 | it('should return the input value unchanged', () => { 118 | expect(identity(5)).toBe(5); 119 | expect(identity('test')).toBe('test'); 120 | expect(identity(null)).toBe(null); 121 | const obj = { a: 1 }; 122 | expect(identity(obj)).toBe(obj); 123 | }); 124 | }); 125 | 126 | describe('constant', () => { 127 | it('should create a function that always returns the same value', () => { 128 | const always5 = constant(5); 129 | expect(always5()).toBe(5); 130 | expect(always5()).toBe(5); 131 | 132 | const alwaysNull = constant(null); 133 | expect(alwaysNull()).toBe(null); 134 | 135 | const obj = { a: 1 }; 136 | const alwaysObj = constant(obj); 137 | expect(alwaysObj()).toBe(obj); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/tests/core/converters/ResourceLinkConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResourceLinkConverter, 3 | extractResourceNames, 4 | } from '../../../core/converters/ResourceLinkConverter'; 5 | 6 | jest.mock('obsidian', () => ({}), { virtual: true }); 7 | jest.mock('fs', () => ({ 8 | mkdirSync: jest.fn(), 9 | copyFile: jest.fn(), 10 | })); 11 | 12 | describe('extract image name', () => { 13 | it('should return image name array', () => { 14 | const context = `![[test.png]] 15 | 16 | test 17 | ![[image.png]] 18 | `; 19 | const result = extractResourceNames(context); 20 | expect(result).toEqual(['test.png', 'image.png']); 21 | }); 22 | 23 | it('should return undefined', () => { 24 | const context = `test`; 25 | const result = extractResourceNames(context); 26 | expect(result).toBeUndefined(); 27 | }); 28 | }); 29 | 30 | describe('convert called', () => { 31 | const converter = new ResourceLinkConverter( 32 | '2023-01-01-post-mock', 33 | 'assets', 34 | 'test', 35 | 'attachments', 36 | 'assets', 37 | ); 38 | 39 | it('should return converted post', () => { 40 | expect(converter.convert(`![[test.png]]`)).toEqual( 41 | `![image](/assets/2023-01-01-post-mock/test.png)`, 42 | ); 43 | }); 44 | }); 45 | 46 | describe('resize image', () => { 47 | const converter = new ResourceLinkConverter( 48 | '2023-01-01-post-mock', 49 | 'assets', 50 | 'test', 51 | 'attachments', 52 | 'assets', 53 | ); 54 | 55 | it('should return converted attachments with width', () => { 56 | expect(converter.convert(`![[test.png|100]]`)).toEqual( 57 | `![image](/assets/2023-01-01-post-mock/test.png){: width="100" }`, 58 | ); 59 | }); 60 | 61 | it('should return converted attachments with width and height', () => { 62 | expect(converter.convert(`![[test.png|100x200]]`)).toEqual( 63 | `![image](/assets/2023-01-01-post-mock/test.png){: width="100" height="200" }`, 64 | ); 65 | }); 66 | 67 | it('should ignore size when image resize syntax was invalid', () => { 68 | expect(converter.convert(`![[test.png|x100]]`)).toEqual( 69 | `![image](/assets/2023-01-01-post-mock/test.png)`, 70 | ); 71 | }); 72 | }); 73 | 74 | describe('image caption', () => { 75 | const converter = new ResourceLinkConverter( 76 | '2023-01-01-post-mock', 77 | 'assets', 78 | 'test', 79 | 'attachments', 80 | 'assets', 81 | ); 82 | 83 | it('should remove blank line after attachments', () => { 84 | const context = ` 85 | ![[test.png]] 86 | 87 | _This is a test image._ 88 | `; 89 | 90 | const result = converter.convert(context); 91 | expect(result).toEqual(` 92 | ![image](/assets/2023-01-01-post-mock/test.png) 93 | _This is a test image._ 94 | `); 95 | }); 96 | 97 | it('should insert next line if no more space after attachment', () => { 98 | const context = ` 99 | ![[test.png]]_This is a test image._ 100 | `; 101 | 102 | const result = converter.convert(context); 103 | expect(result).toEqual(` 104 | ![image](/assets/2023-01-01-post-mock/test.png) 105 | _This is a test image._ 106 | `); 107 | }); 108 | 109 | it('should nothing if exist just one blank line', () => { 110 | const context = ` 111 | ![[test.png]] 112 | _This is a test image._ 113 | `; 114 | 115 | const result = converter.convert(context); 116 | expect(result).toEqual(` 117 | ![image](/assets/2023-01-01-post-mock/test.png) 118 | _This is a test image._ 119 | `); 120 | }); 121 | 122 | it('should nothing if does not exist image caption', () => { 123 | const context = ` 124 | ![[test.png]] 125 | 126 | ## Header 127 | 128 | `; 129 | 130 | const result = converter.convert(context); 131 | expect(result).toEqual(` 132 | ![image](/assets/2023-01-01-post-mock/test.png) 133 | 134 | ## Header 135 | 136 | `); 137 | }); 138 | }); 139 | 140 | describe('liquid filter with relative_url', () => { 141 | const converter = new ResourceLinkConverter( 142 | '2023-01-01-post-mock', 143 | 'assets', 144 | 'test', 145 | 'attachments', 146 | 'assets', 147 | { useRelativeUrl: true }, 148 | ); 149 | 150 | it('should wrap image path with relative_url filter', () => { 151 | const context = `![[test.png]]`; 152 | const result = converter.convert(context); 153 | expect(result).toEqual( 154 | `![image]({{ "/assets/2023-01-01-post-mock/test.png" | relative_url }})`, 155 | ); 156 | }); 157 | 158 | it('should handle images with size specifications', () => { 159 | const context = `![[test.png|100x200]]`; 160 | const result = converter.convert(context); 161 | expect(result).toEqual( 162 | `![image]({{ "/assets/2023-01-01-post-mock/test.png" | relative_url }}){: width="100" height="200" }`, 163 | ); 164 | }); 165 | 166 | it('should handle images with captions', () => { 167 | const context = `![[test.png]]\n_Image caption_`; 168 | const result = converter.convert(context); 169 | expect(result).toEqual( 170 | `![image]({{ "/assets/2023-01-01-post-mock/test.png" | relative_url }})\n_Image caption_`, 171 | ); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/tests/platforms/DateExtractionPattern.test.ts: -------------------------------------------------------------------------------- 1 | import { DateExtractionPattern } from '../../platforms/docusaurus/DateExtractionPattern'; 2 | 3 | describe('SINGLE', () => { 4 | it('should match YYYY-MM-DD-my-blog-post-title.md', () => { 5 | const regex = DateExtractionPattern['SINGLE'].regexp; 6 | const actual = '2021-02-01-my-blog-post-title.md'; 7 | expect(actual).toMatch(regex); 8 | 9 | const match = actual.match(regex); 10 | if (match) { 11 | expect(match[1]).toBe('2021'); 12 | expect(match[2]).toBe('02'); 13 | expect(match[3]).toBe('01'); 14 | expect(match[4]).toBe('my-blog-post-title'); 15 | } 16 | }); 17 | 18 | it('should replace YYYY-MM-DD-my-blog-post-title.md', () => { 19 | const replacer = DateExtractionPattern['SINGLE'].replacer; 20 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 21 | '2021-02-01-my-blog-post-title.md', 22 | ); 23 | }); 24 | }); 25 | 26 | describe('MDX', () => { 27 | it('should match YYYY-MM-DD-my-blog-post-title.mdx', () => { 28 | const regex = DateExtractionPattern['MDX'].regexp; 29 | expect('o2-temp.2021-02-01-my-blog-post-title.mdx').toMatch(regex); 30 | }); 31 | 32 | it('should replace YYYY-MM-DD-my-blog-post-title.mdx', () => { 33 | const replacer = DateExtractionPattern['MDX'].replacer; 34 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 35 | '2021-02-01-my-blog-post-title.mdx', 36 | ); 37 | }); 38 | }); 39 | 40 | describe('SINGLE_FOLDER_INDEX', () => { 41 | it('should match YYYY-MM-DD-my-blog-post-title/index.md', () => { 42 | const regex = DateExtractionPattern['SINGLE_FOLDER_INDEX'].regexp; 43 | expect('o2-temp.2021-02-01-my-blog-post-title/index.md').toMatch(regex); 44 | }); 45 | 46 | it('should replace YYYY-MM-DD-my-blog-post-title/index.md', () => { 47 | const replacer = DateExtractionPattern['SINGLE_FOLDER_INDEX'].replacer; 48 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 49 | '2021-02-01-my-blog-post-title/index.md', 50 | ); 51 | }); 52 | }); 53 | 54 | describe('FOLDER_NAMED_BY_DATE', () => { 55 | it('should match YYYY-MM-DD/my-blog-post-title.md', () => { 56 | const regex = DateExtractionPattern['FOLDER_NAMED_BY_DATE'].regexp; 57 | expect('o2-temp.2021-02-01/my-blog-post-title.md').toMatch(regex); 58 | }); 59 | 60 | it('should replace YYYY-MM-DD/my-blog-post-title.md', () => { 61 | const replacer = DateExtractionPattern['FOLDER_NAMED_BY_DATE'].replacer; 62 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 63 | '2021-02-01/my-blog-post-title.md', 64 | ); 65 | }); 66 | }); 67 | 68 | describe('NESTED_FOLDERS_BY_DATE', () => { 69 | it('should match YYYY/MM/DD/my-blog-post-title.md', () => { 70 | const regex = DateExtractionPattern['NESTED_FOLDERS_BY_DATE'].regexp; 71 | expect('o2-temp.2021/02/01/my-blog-post-title.md').toMatch(regex); 72 | }); 73 | 74 | it('should replace YYYY/MM/DD/my-blog-post-title.md', () => { 75 | const replacer = DateExtractionPattern['NESTED_FOLDERS_BY_DATE'].replacer; 76 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 77 | '2021/02/01/my-blog-post-title.md', 78 | ); 79 | }); 80 | }); 81 | 82 | describe('PARTIALLY_NESTED_FOLDERS_BY_DATE', () => { 83 | it('should match YYYY/MM-DD/my-blog-post-title.md', () => { 84 | const regex = 85 | DateExtractionPattern['PARTIALLY_NESTED_FOLDERS_BY_DATE'].regexp; 86 | expect('o2-temp.2021/02-01/my-blog-post-title.md').toMatch(regex); 87 | }); 88 | 89 | it('should replace YYYY/MM-DD/my-blog-post-title.md', () => { 90 | const replacer = 91 | DateExtractionPattern['PARTIALLY_NESTED_FOLDERS_BY_DATE'].replacer; 92 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 93 | '2021/02-01/my-blog-post-title.md', 94 | ); 95 | }); 96 | }); 97 | 98 | describe('NESTED_FOLDERS_INDEX', () => { 99 | it('should match YYYY/MM/DD/my-blog-post-title/index.md', () => { 100 | const regex = DateExtractionPattern['NESTED_FOLDERS_INDEX'].regexp; 101 | expect('o2-temp.2021/02/01/my-blog-post-title/index.md').toMatch(regex); 102 | }); 103 | 104 | it('should replace YYYY/MM/DD/my-blog-post-title/index.md', () => { 105 | const replacer = DateExtractionPattern['NESTED_FOLDERS_INDEX'].replacer; 106 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 107 | '2021/02/01/my-blog-post-title/index.md', 108 | ); 109 | }); 110 | }); 111 | 112 | describe('DATE_IN_MIDDLE_OF_PATH', () => { 113 | it('should match category/YYYY/MM-DD-my-blog-post-title.md', () => { 114 | const regex = DateExtractionPattern['DATE_IN_MIDDLE_OF_PATH'].regexp; 115 | expect('o2-temp.category/2021/02-01-my-blog-post-title.md').toMatch(regex); 116 | }); 117 | 118 | it('should replace category/YYYY/MM-DD-my-blog-post-title.md', () => { 119 | const replacer = DateExtractionPattern['DATE_IN_MIDDLE_OF_PATH'].replacer; 120 | expect(replacer('2021', '02', '01', 'my-blog-post-title')).toBe( 121 | 'category/2021/02-01-my-blog-post-title.md', 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/tests/core/converters/CalloutConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CalloutConverter, 3 | convertDocusaurusCallout, 4 | } from '../../../core/converters/CalloutConverter'; 5 | 6 | const calloutConverter = new CalloutConverter(); 7 | 8 | describe('Jekyll: convert callout syntax', () => { 9 | it.each([ 10 | ['note'], 11 | ['todo'], 12 | ['example'], 13 | ['quote'], 14 | ['cite'], 15 | ['success'], 16 | ['done'], 17 | ['check'], 18 | ['NOTE'], 19 | ['TODO'], 20 | ['EXAMPLE'], 21 | ['QUOTE'], 22 | ['CITE'], 23 | ['SUCCESS'], 24 | ['DONE'], 25 | ['CHECK'], 26 | ])('%s => info', callout => { 27 | const context = `> [!${callout}] title\n> content`; 28 | 29 | const result = calloutConverter.convert(context); 30 | expect(result).toBe(`> content\n{: .prompt-info}`); 31 | }); 32 | 33 | it.each([ 34 | ['tip'], 35 | ['hint'], 36 | ['important'], 37 | ['question'], 38 | ['help'], 39 | ['faq'], 40 | ['TIP'], 41 | ['HINT'], 42 | ['IMPORTANT'], 43 | ['QUESTION'], 44 | ['HELP'], 45 | ['FAQ'], 46 | ])('%s => tip', callout => { 47 | const context = `> [!${callout}] title\n> content`; 48 | 49 | const result = calloutConverter.convert(context); 50 | expect(result).toBe(`> content\n{: .prompt-tip}`); 51 | }); 52 | 53 | it.each([ 54 | ['warning'], 55 | ['caution'], 56 | ['attention'], 57 | ['WARNING'], 58 | ['CAUTION'], 59 | ['ATTENTION'], 60 | ])('%s => warning', callout => { 61 | const context = `> [!${callout}] title\n> content`; 62 | 63 | const result = calloutConverter.convert(context); 64 | expect(result).toBe(`> content\n{: .prompt-warning}`); 65 | }); 66 | 67 | it.each([['unknown']])( 68 | 'Unregistered keywords should be converted to info keyword', 69 | callout => { 70 | const context = `> [!${callout}] title\n> content`; 71 | 72 | const result = calloutConverter.convert(context); 73 | expect(result).toBe(`> content\n{: .prompt-info}`); 74 | }, 75 | ); 76 | 77 | it('info => info, not exist custom title', () => { 78 | const context = `> [!INFO]\n> info content`; 79 | 80 | const result = calloutConverter.convert(context); 81 | expect(result).toBe(`> info content\n{: .prompt-info}`); 82 | }); 83 | 84 | it('remove foldable callouts', () => { 85 | const context = `> [!faq]- Are callouts foldable?\n> content`; 86 | const result = calloutConverter.convert(context); 87 | expect(result).toBe(`> content\n{: .prompt-tip}`); 88 | }); 89 | }); 90 | 91 | describe('Docusaurus: convert callout syntax', () => { 92 | it.each([ 93 | ['todo'], 94 | ['example'], 95 | ['quote'], 96 | ['cite'], 97 | ['success'], 98 | ['done'], 99 | ['check'], 100 | ['TODO'], 101 | ['EXAMPLE'], 102 | ['QUOTE'], 103 | ['CITE'], 104 | ['SUCCESS'], 105 | ['DONE'], 106 | ['CHECK'], 107 | ])('%s => info', callout => { 108 | const context = `> [!${callout}] This is Title!\n> content`; 109 | 110 | const result = convertDocusaurusCallout(context); 111 | expect(result).toBe(`:::note[This is Title!]\n\ncontent\n\n:::`); 112 | }); 113 | 114 | it.each([['note'], ['NOTE']])('%s => note', callout => { 115 | const context = `> [!${callout}] This is Title!\n> content`; 116 | 117 | const result = convertDocusaurusCallout(context); 118 | expect(result).toBe(`:::note[This is Title!]\n\ncontent\n\n:::`); 119 | }); 120 | 121 | it.each([ 122 | ['tip'], 123 | ['hint'], 124 | ['important'], 125 | ['question'], 126 | ['help'], 127 | ['TIP'], 128 | ['HINT'], 129 | ['IMPORTANT'], 130 | ['QUESTION'], 131 | ['HELP'], 132 | ])('%s => tip', callout => { 133 | const context = `> [!${callout}] This is Title!\n> content`; 134 | 135 | const result = convertDocusaurusCallout(context); 136 | expect(result).toBe(`:::tip[This is Title!]\n\ncontent\n\n:::`); 137 | }); 138 | 139 | it.each([ 140 | ['warning'], 141 | ['caution'], 142 | ['attention'], 143 | ['WARNING'], 144 | ['CAUTION'], 145 | ['ATTENTION'], 146 | ])('%s => warning', callout => { 147 | const context = `> [!${callout}] This is Title!\n> content`; 148 | 149 | const result = convertDocusaurusCallout(context); 150 | expect(result).toBe(`:::warning[This is Title!]\n\ncontent\n\n:::`); 151 | }); 152 | 153 | it('unknown => note', () => { 154 | const context = `> [!unknown] This is Title!\n> content`; 155 | 156 | const result = convertDocusaurusCallout(context); 157 | expect(result).toBe(`:::note[This is Title!]\n\ncontent\n\n:::`); 158 | }); 159 | 160 | it('not exist custom title', () => { 161 | const context = `> [!INFO]\n> content`; 162 | 163 | const result = convertDocusaurusCallout(context); 164 | expect(result).toBe(`:::info\n\ncontent\n\n:::`); 165 | }); 166 | }); 167 | 168 | describe('danger callout', () => { 169 | const dangerKeyword = [ 170 | ['error'], 171 | ['danger'], 172 | ['bug'], 173 | ['failure'], 174 | ['fail'], 175 | ['missing'], 176 | ['ERROR'], 177 | ['DANGER'], 178 | ['BUG'], 179 | ['FAILURE'], 180 | ['FAIL'], 181 | ['MISSING'], 182 | ]; 183 | 184 | it.each(dangerKeyword)('%s => danger', callout => { 185 | const context = `> [!${callout}] This is Title!\n> lorem it sum`; 186 | 187 | const result = convertDocusaurusCallout(context); 188 | expect(result).toBe(`:::danger[This is Title!]\n\nlorem it sum\n\n:::`); 189 | }); 190 | 191 | it.each(dangerKeyword)('%s => danger', callout => { 192 | const context = `> [!${callout}] title\n> content`; 193 | 194 | const result = calloutConverter.convert(context); 195 | expect(result).toBe(`> content\n{: .prompt-danger}`); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /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, religion, or sexual identity 10 | 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 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of 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 35 | address, 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 email 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 | . 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 86 | of 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 93 | permanent 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 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available 119 | at [contributor-covenant.org](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ 127 | at [contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). 128 | Translations are available 129 | at [contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). 130 | -------------------------------------------------------------------------------- /src/core/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { App, FileSystemAdapter, Notice, TFile } from 'obsidian'; 2 | import { Temporal } from '@js-temporal/polyfill'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { DateExtractionPattern } from '../../platforms/docusaurus/DateExtractionPattern'; 6 | import { ObsidianPathSettings } from '../../settings'; 7 | 8 | export const TEMP_PREFIX = 'o2-temp.' as const; 9 | 10 | export type AppWithVault = Pick; 11 | export type PluginWithApp = { app: AppWithVault }; 12 | export type PluginWithSettings = { 13 | app: AppWithVault; 14 | obsidianPathSettings: ObsidianPathSettings; 15 | }; 16 | 17 | export function vaultAbsolutePath(plugin: PluginWithApp): string { 18 | const adapter = plugin.app.vault.adapter; 19 | if (adapter instanceof FileSystemAdapter) { 20 | return adapter.getBasePath(); 21 | } 22 | new Notice('Vault is not a file system adapter'); 23 | throw new Error('Vault is not a file system adapter'); 24 | } 25 | 26 | export const copyMarkdownFile = async ( 27 | plugin: PluginWithSettings, 28 | ): Promise => { 29 | const dateString = Temporal.Now.plainDateISO().toString(); 30 | const markdownFiles = getFilesInReady(plugin); 31 | for (const file of markdownFiles) { 32 | const newFileName = TEMP_PREFIX + dateString + '-' + file.name; 33 | const newPath = file.path 34 | .replace(file.name, newFileName) 35 | .replace(/,+/g, '') 36 | .replace(/\s/g, '-'); 37 | 38 | await plugin.app.vault.copy(file, newPath).catch(error => { 39 | console.error(error); 40 | new Notice('Failed to copy file, see console for more information.'); 41 | }); 42 | } 43 | 44 | // collect copied files 45 | return plugin.app.vault 46 | .getMarkdownFiles() 47 | .filter((file: TFile) => file.path.includes(TEMP_PREFIX)); 48 | }; 49 | 50 | export const getFilesInReady = (plugin: PluginWithSettings): TFile[] => 51 | plugin.app.vault 52 | .getMarkdownFiles() 53 | .filter((file: TFile) => 54 | file.path.startsWith(plugin.obsidianPathSettings.readyFolder), 55 | ); 56 | 57 | const copyFile = (sourceFilePath: string, targetFilePath: string) => { 58 | // if directory not exist create it 59 | const targetDirectory = path.dirname(targetFilePath); 60 | if (!fs.existsSync(targetDirectory)) { 61 | fs.mkdirSync(targetDirectory, { recursive: true }); 62 | } 63 | fs.copyFileSync(sourceFilePath, targetFilePath); 64 | }; 65 | 66 | export const copy = ( 67 | sourceFolderPath: string, 68 | targetFolderPath: string, 69 | replacer: (year: string, month: string, day: string, title: string) => string, 70 | publishedDate?: LocalDate, 71 | ) => { 72 | fs.readdirSync(sourceFolderPath) 73 | .filter(filename => filename.startsWith(TEMP_PREFIX)) 74 | .forEach(filename => { 75 | const transformedFileName = transformPath( 76 | filename, 77 | replacer, 78 | publishedDate, 79 | ); 80 | 81 | const sourceFilePath = path.join(sourceFolderPath, filename); 82 | const targetFilePath = path.join( 83 | targetFolderPath, 84 | transformedFileName.replace(TEMP_PREFIX, '').replace(/\s/g, '-'), 85 | ); 86 | 87 | copyFile(sourceFilePath, targetFilePath); 88 | }); 89 | }; 90 | 91 | export const archiving = async (plugin: PluginWithSettings) => { 92 | if (!plugin.obsidianPathSettings.isAutoArchive) { 93 | return; 94 | } 95 | 96 | // move files to archive folder 97 | const readyFiles = getFilesInReady(plugin); 98 | readyFiles.forEach((file: TFile) => { 99 | plugin.app.fileManager?.renameFile( 100 | file, 101 | file.path.replace( 102 | plugin.obsidianPathSettings.readyFolder, 103 | plugin.obsidianPathSettings.archiveFolder, 104 | ), 105 | ); 106 | }); 107 | }; 108 | 109 | export const moveFiles = async ( 110 | sourceFolderPath: string, 111 | targetFolderPath: string, 112 | pathReplacer: ( 113 | year: string, 114 | month: string, 115 | day: string, 116 | title: string, 117 | ) => string, 118 | publishedDate?: LocalDate, 119 | ) => { 120 | copy(sourceFolderPath, targetFolderPath, pathReplacer, publishedDate); 121 | }; 122 | 123 | export const cleanUp = async (plugin: PluginWithApp) => { 124 | // remove temp files 125 | const markdownFiles = plugin.app.vault 126 | .getMarkdownFiles() 127 | .filter(file => file.path.includes(TEMP_PREFIX)); 128 | 129 | for (const file of markdownFiles) { 130 | await plugin.app.vault.delete(file).then(() => { 131 | console.log(`Deleted temp file: ${file.path}`); 132 | }); 133 | } 134 | }; 135 | 136 | // prepare path related to docusaurus date extraction type 137 | // e.g. directory candidates that should be created have to refer to date extraction type. 138 | // return path to be created, and this path is target path 139 | const transformPath = ( 140 | input: string, 141 | replacer: ( 142 | year: string | number, 143 | month: string | number, 144 | day: string | number, 145 | title: string, 146 | ) => string, 147 | date?: LocalDate, 148 | ): string => { 149 | const match = input.match(DateExtractionPattern['SINGLE'].regexp); 150 | if (match) { 151 | const year = date ? date.year : match[1]; 152 | const month = date ? date.month : match[2]; 153 | const day = date ? date.day : match[3]; 154 | const title = match[4]; 155 | return replacer(year, month, day, title); 156 | } 157 | return input; 158 | }; 159 | 160 | interface LocalDate { 161 | year: string; 162 | month: string; 163 | day: string; 164 | } 165 | 166 | export const parseLocalDate = (date: string): LocalDate => { 167 | const match = date.match(/(\d{4})-(\d{2})-(\d{2})/); 168 | if (!match) { 169 | throw new Error('Invalid date format'); 170 | } 171 | return { 172 | year: match[1], 173 | month: match[2], 174 | day: match[3], 175 | }; 176 | }; 177 | -------------------------------------------------------------------------------- /src/platforms/docusaurus/docusaurus.ts: -------------------------------------------------------------------------------- 1 | import { Notice, TFile } from 'obsidian'; 2 | import { convertDocusaurusCallout } from '../../core/converters/CalloutConverter'; 3 | import { convertComments } from '../../core/converters/CommentsConverter'; 4 | import { convertFootnotes } from '../../core/converters/FootnotesConverter'; 5 | import { convertFrontMatter } from '../../core/converters/FrontMatterConverter'; 6 | import { convertWikiLink } from '../../core/converters/WikiLinkConverter'; 7 | import { pipe } from '../../core/fp'; 8 | import { 9 | chain, 10 | ConversionError, 11 | Either, 12 | fold, 13 | left, 14 | map, 15 | right, 16 | } from '../../core/types/types'; 17 | import { 18 | copyMarkdownFile, 19 | getFilesInReady, 20 | moveFiles, 21 | parseLocalDate, 22 | vaultAbsolutePath, 23 | } from '../../core/utils/utils'; 24 | import O2Plugin from '../../main'; 25 | 26 | // Pure functions 27 | export const getCurrentDate = (): string => 28 | new Date().toISOString().split('T')[0]; 29 | 30 | export const convertContent = (value: string): string => 31 | pipe( 32 | value, 33 | convertWikiLink, 34 | convertFootnotes, 35 | convertDocusaurusCallout, 36 | convertComments, 37 | ); 38 | 39 | // File operations with Either 40 | const processFrontMatter = async ( 41 | plugin: O2Plugin, 42 | file: TFile, 43 | published?: string, 44 | ): Promise> => { 45 | try { 46 | await plugin.app.fileManager.processFrontMatter(file, fm => { 47 | if (published) { 48 | fm.published = published; 49 | } 50 | return fm; 51 | }); 52 | return right(published || getCurrentDate()); 53 | } catch (error) { 54 | return left({ 55 | type: 'PROCESS_ERROR', 56 | message: `Failed to process front matter: ${ 57 | error instanceof Error ? error.message : 'Unknown error' 58 | }`, 59 | }); 60 | } 61 | }; 62 | 63 | const readFileContent = async ( 64 | plugin: O2Plugin, 65 | file: TFile, 66 | ): Promise> => { 67 | try { 68 | const content = await plugin.app.vault.read(file); 69 | return right(content); 70 | } catch (error) { 71 | return left({ 72 | type: 'READ_ERROR', 73 | message: `Failed to read file: ${ 74 | error instanceof Error ? error.message : 'Unknown error' 75 | }`, 76 | }); 77 | } 78 | }; 79 | 80 | const writeFileContent = async ( 81 | plugin: O2Plugin, 82 | file: TFile, 83 | content: string, 84 | ): Promise> => { 85 | try { 86 | await plugin.app.vault.modify(file, content); 87 | return right(undefined); 88 | } catch (error) { 89 | return left({ 90 | type: 'WRITE_ERROR', 91 | message: `Failed to write file: ${ 92 | error instanceof Error ? error.message : 'Unknown error' 93 | }`, 94 | }); 95 | } 96 | }; 97 | 98 | const moveToDocusaurus = async ( 99 | plugin: O2Plugin, 100 | publishedDate: string, 101 | ): Promise> => { 102 | try { 103 | await moveFiles( 104 | `${vaultAbsolutePath(plugin)}/${plugin.obsidianPathSettings.readyFolder}`, 105 | plugin.docusaurus.targetPath(), 106 | plugin.docusaurus.pathReplacer, 107 | parseLocalDate(publishedDate), 108 | ); 109 | return right(undefined); 110 | } catch (error) { 111 | return left({ 112 | type: 'MOVE_ERROR', 113 | message: `Failed to move files: ${ 114 | error instanceof Error ? error.message : 'Unknown error' 115 | }`, 116 | }); 117 | } 118 | }; 119 | 120 | // Side effects 121 | const showNotice = (message: string, duration = 5000): void => { 122 | new Notice(message, duration); 123 | }; 124 | 125 | const markPublished = async (plugin: O2Plugin): Promise => { 126 | const filesInReady = getFilesInReady(plugin); 127 | const currentDate = getCurrentDate(); 128 | 129 | for (const file of filesInReady) { 130 | const result = await processFrontMatter(plugin, file, currentDate); 131 | fold( 132 | error => showNotice(`Failed to mark as published: ${error.message}`), 133 | () => {}, 134 | )(result); 135 | } 136 | }; 137 | 138 | // Main conversion function 139 | export const convertToDocusaurus = async (plugin: O2Plugin): Promise => { 140 | const markdownFiles = await copyMarkdownFile(plugin); 141 | 142 | for (const file of markdownFiles) { 143 | const publishedDateResult = await processFrontMatter(plugin, file); 144 | const contentResult = await readFileContent(plugin, file); 145 | 146 | const conversionResult = pipe( 147 | contentResult, 148 | chain((content: string) => 149 | convertFrontMatter(content, { 150 | authors: plugin.docusaurus.authors, 151 | }), 152 | ), 153 | map((content: string) => convertContent(content)), 154 | ); 155 | 156 | const writeResult = await pipe( 157 | conversionResult, 158 | fold>>( 159 | error => { 160 | showNotice(`Conversion failed: ${error.message}`); 161 | return Promise.resolve(left(error)); 162 | }, 163 | content => writeFileContent(plugin, file, content), 164 | ), 165 | ); 166 | 167 | fold( 168 | error => showNotice(`Failed to save changes: ${error.message}`), 169 | () => showNotice('Converted to Docusaurus successfully.'), 170 | )(writeResult); 171 | 172 | const publishedDate = fold( 173 | () => getCurrentDate(), 174 | date => date, 175 | )(publishedDateResult); 176 | 177 | const moveResult = await moveToDocusaurus(plugin, publishedDate); 178 | 179 | fold( 180 | error => showNotice(`Failed to move files: ${error.message}`), 181 | async () => { 182 | await markPublished(plugin); 183 | showNotice('Moved files to Docusaurus successfully.'); 184 | }, 185 | )(moveResult); 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /src/core/converters/FrontMatterConverter.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | import { 3 | ConversionError, 4 | ConversionResult, 5 | Either, 6 | FrontMatter, 7 | left, 8 | right, 9 | map, 10 | } from '../types/types'; 11 | import { ObsidianRegex } from '../ObsidianRegex'; 12 | import { pipe } from '../fp'; 13 | 14 | // Helper functions 15 | const isNullOrEmpty = (str: string | undefined | null): boolean => 16 | !str || (typeof str === 'string' && str.trim().length === 0); 17 | 18 | const formatDate = (date: unknown): string => { 19 | if (typeof date === 'string') return date; 20 | if (date instanceof Date) { 21 | return date.toISOString().split('T')[0]; 22 | } 23 | return String(date); 24 | }; 25 | 26 | const formatAuthorList = (authors: string): string => { 27 | const authorList = authors.split(',').map(author => author.trim()); 28 | return authorList.length > 1 ? `[${authorList.join(', ')}]` : authorList[0]; 29 | }; 30 | 31 | // Pure functions for front matter operations 32 | const extractFrontMatter = ( 33 | content: string, 34 | ): Either => { 35 | if (!content.startsWith('---\n')) { 36 | return right({ frontMatter: {}, body: content }); 37 | } 38 | 39 | const endOfFrontMatter = content.indexOf('\n---', 3); 40 | if (endOfFrontMatter === -1) { 41 | return right({ frontMatter: {}, body: content }); 42 | } 43 | 44 | const frontMatterLines = content.substring(4, endOfFrontMatter); 45 | const body = content.substring(endOfFrontMatter + 4).trimStart(); 46 | 47 | try { 48 | const frontMatter = yaml.load(frontMatterLines, { 49 | schema: yaml.JSON_SCHEMA, 50 | }) as FrontMatter; 51 | return right({ frontMatter: frontMatter || {}, body }); 52 | } catch (e) { 53 | return left({ 54 | type: 'PARSE_ERROR', 55 | message: `Failed to parse front matter: ${ 56 | e instanceof Error ? e.message : 'Unknown error' 57 | }`, 58 | }); 59 | } 60 | }; 61 | 62 | const formatTitle = (frontMatter: FrontMatter): FrontMatter => { 63 | if (!frontMatter.title) return frontMatter; 64 | return { 65 | ...frontMatter, 66 | title: frontMatter.title.startsWith('"') 67 | ? frontMatter.title 68 | : `"${frontMatter.title}"`, 69 | }; 70 | }; 71 | 72 | const formatCategories = (frontMatter: FrontMatter): FrontMatter => { 73 | if (!frontMatter.categories) return frontMatter; 74 | 75 | const categories = JSON.stringify(frontMatter.categories).startsWith('[') 76 | ? JSON.stringify(frontMatter.categories) 77 | .replace(/,/g, ', ') 78 | .replace(/"/g, '') 79 | : frontMatter.categories; 80 | 81 | return { ...frontMatter, categories }; 82 | }; 83 | 84 | const formatAuthors = (frontMatter: FrontMatter): FrontMatter => { 85 | if (!frontMatter.authors) return frontMatter; 86 | 87 | const authors = frontMatter.authors; 88 | if (authors.startsWith('[') && authors.endsWith(']')) return frontMatter; 89 | 90 | return { 91 | ...frontMatter, 92 | authors: formatAuthorList(authors), 93 | }; 94 | }; 95 | 96 | const formatTags = (frontMatter: FrontMatter): FrontMatter => { 97 | if (!frontMatter.tags) return frontMatter; 98 | 99 | const tags = Array.isArray(frontMatter.tags) 100 | ? `[${frontMatter.tags.join(', ')}]` 101 | : frontMatter.tags 102 | ? `[${frontMatter.tags}]` 103 | : '[]'; 104 | 105 | return { ...frontMatter, tags }; 106 | }; 107 | 108 | const handleMermaid = (result: ConversionResult): ConversionResult => ({ 109 | ...result, 110 | frontMatter: result.body.match(/```mermaid/) 111 | ? { ...result.frontMatter, mermaid: 'true' } 112 | : result.frontMatter, 113 | }); 114 | 115 | const convertImagePath = 116 | (postTitle: string, resourcePath: string) => 117 | (imagePath: string): string => 118 | `/${resourcePath}/${postTitle}/${imagePath}`; 119 | 120 | const handleImageFrontMatter = 121 | (isEnable: boolean, fileName: string, resourcePath: string) => 122 | (frontMatter: FrontMatter): FrontMatter => { 123 | if (!isEnable || !frontMatter.image) return frontMatter; 124 | 125 | const match = ObsidianRegex.ATTACHMENT_LINK.exec(frontMatter.image); 126 | const processedImage = match 127 | ? `${match[1]}.${match[2]}` 128 | : frontMatter.image; 129 | const finalImage = convertImagePath(fileName, resourcePath)(processedImage); 130 | 131 | return { ...frontMatter, image: finalImage }; 132 | }; 133 | 134 | const handleDateFrontMatter = 135 | (isEnable: boolean) => 136 | (frontMatter: FrontMatter): FrontMatter => { 137 | if (!isEnable || isNullOrEmpty(frontMatter.updated)) return frontMatter; 138 | 139 | const { updated, ...rest } = frontMatter; 140 | return { ...rest, date: formatDate(updated) }; 141 | }; 142 | 143 | const serializeFrontMatter = (result: ConversionResult): string => { 144 | if (Object.keys(result.frontMatter).length === 0) { 145 | return result.body; 146 | } 147 | 148 | return `--- 149 | ${Object.entries(result.frontMatter) 150 | .map(([key, value]) => `${key}: ${value}`) 151 | .join('\n')} 152 | --- 153 | 154 | ${result.body}`; 155 | }; 156 | 157 | // Main conversion function 158 | export const convertFrontMatter = ( 159 | input: string, 160 | options: Readonly<{ 161 | fileName?: string; 162 | resourcePath?: string; 163 | isEnableBanner?: boolean; 164 | isEnableUpdateFrontmatterTimeOnEdit?: boolean; 165 | authors?: string; 166 | }> = {}, 167 | ): Either => { 168 | const { 169 | fileName = '', 170 | resourcePath = '', 171 | isEnableBanner = false, 172 | isEnableUpdateFrontmatterTimeOnEdit = false, 173 | authors, 174 | } = options; 175 | 176 | const processFrontMatter = (frontMatter: FrontMatter): FrontMatter => { 177 | const withTitle = formatTitle(frontMatter); 178 | const withCategories = formatCategories(withTitle); 179 | const withAuthors = formatAuthors(withCategories); 180 | const withTags = formatTags(withAuthors); 181 | const withImage = handleImageFrontMatter( 182 | isEnableBanner, 183 | fileName, 184 | resourcePath, 185 | )(withTags); 186 | const withDate = handleDateFrontMatter(isEnableUpdateFrontmatterTimeOnEdit)( 187 | withImage, 188 | ); 189 | return authors 190 | ? { ...withDate, authors: formatAuthorList(authors) } 191 | : withDate; 192 | }; 193 | 194 | return pipe( 195 | extractFrontMatter(input), 196 | map(result => ({ 197 | ...result, 198 | frontMatter: processFrontMatter(result.frontMatter), 199 | })), 200 | map(handleMermaid), 201 | map(serializeFrontMatter), 202 | ); 203 | }; 204 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian'; 2 | import O2Plugin from './main'; 3 | import JekyllSettings from './platforms/jekyll/settings/JekyllSettings'; 4 | import DocusaurusSettings from './platforms/docusaurus/settings/DocusaurusSettings'; 5 | import { DateExtractionPattern } from './platforms/docusaurus/DateExtractionPattern'; 6 | 7 | export class ObsidianPathSettings { 8 | readyFolder: string = 'ready'; 9 | archiveFolder: string = 'archive'; 10 | attachmentsFolder: string = 'attachments'; 11 | isAutoArchive: boolean = false; 12 | isAutoCreateFolder: boolean = false; 13 | } 14 | 15 | export interface O2PluginSettings { 16 | targetPath(): string; 17 | 18 | resourcePath(): string; 19 | 20 | afterPropertiesSet(): boolean; 21 | 22 | pathReplacer(year: string, month: string, day: string, title: string): string; 23 | } 24 | 25 | export class O2SettingTab extends PluginSettingTab { 26 | plugin: O2Plugin; 27 | 28 | constructor(app: App, plugin: O2Plugin) { 29 | super(app, plugin); 30 | this.plugin = plugin; 31 | } 32 | 33 | display(): void { 34 | this.containerEl.empty(); // Clear the container. prevent duplicate settings 35 | this.containerEl.createEl('h1', { 36 | text: 'Settings for O2 plugin', 37 | }); 38 | 39 | this.containerEl.createEl('h3', { 40 | text: 'Path Settings', 41 | }); 42 | this.addReadyFolderSetting(); 43 | this.addArchiveFolderSetting(); 44 | this.addAttachmentsFolderSetting(); 45 | 46 | this.containerEl.createEl('h3', { 47 | text: 'Features', 48 | }); 49 | this.enableCurlyBraceSetting(); 50 | this.enableUpdateFrontmatterTimeOnEditSetting(); 51 | this.enableAutoCreateFolderSetting(); 52 | this.enableAutoArchiveSetting(); 53 | 54 | // jekyll settings 55 | this.containerEl.createEl('h3', { 56 | text: 'Jekyll', 57 | }); 58 | this.addJekyllPathSetting(); 59 | this.addJekyllRelativeResourcePathSetting(); 60 | //// liquidFilter; 61 | this.containerEl.createEl('h5', { 62 | text: 'Liquid Filter', 63 | }); 64 | this.addJekyllRelativeUrlSetting(); 65 | 66 | // docusaurus settings 67 | this.containerEl.createEl('h3', { 68 | text: 'Docusaurus', 69 | }); 70 | this.addDocusaurusPathSetting(); 71 | this.dateExtractionPatternSetting(); 72 | this.addDocusaurusAuthorsSetting(); 73 | } 74 | 75 | private addDocusaurusAuthorsSetting() { 76 | const docusaurus = this.plugin.docusaurus as DocusaurusSettings; 77 | new Setting(this.containerEl) 78 | .setName('Docusaurus authors') 79 | .setDesc( 80 | 'Author(s) for Docusaurus front matter. For multiple authors, separate with commas.', 81 | ) 82 | .addText(text => 83 | text 84 | .setPlaceholder('jmarcey, slorber') 85 | .setValue(docusaurus.authors) 86 | .onChange(async value => { 87 | docusaurus.authors = value; 88 | await this.plugin.saveSettings(); 89 | }), 90 | ); 91 | } 92 | 93 | private enableUpdateFrontmatterTimeOnEditSetting() { 94 | const jekyllSetting = this.plugin.jekyll as JekyllSettings; 95 | new Setting(this.containerEl) 96 | .setName('Replace date frontmatter to updated time') 97 | .setDesc( 98 | "If 'updated' frontmatter exists, replace the value of 'date' frontmatter with the value of 'updated' frontmatter.", 99 | ) 100 | .addToggle(toggle => 101 | toggle 102 | .setValue(jekyllSetting.isEnableUpdateFrontmatterTimeOnEdit) 103 | .onChange(async value => { 104 | jekyllSetting.isEnableUpdateFrontmatterTimeOnEdit = value; 105 | await this.plugin.saveSettings(); 106 | }), 107 | ); 108 | } 109 | 110 | private enableAutoCreateFolderSetting() { 111 | new Setting(this.containerEl) 112 | .setName('Auto create folders') 113 | .setDesc('Automatically create necessary folders if they do not exist.') 114 | .addToggle(toggle => 115 | toggle 116 | .setValue(this.plugin.obsidianPathSettings.isAutoCreateFolder) 117 | .onChange(async value => { 118 | this.plugin.obsidianPathSettings.isAutoCreateFolder = value; 119 | await this.plugin.saveSettings(); 120 | }), 121 | ); 122 | } 123 | 124 | private enableCurlyBraceSetting() { 125 | const jekyllSetting = this.plugin.jekyll as JekyllSettings; 126 | new Setting(this.containerEl) 127 | .setName('Curly Brace Conversion') 128 | .setDesc('Convert double curly braces to jekyll raw tag.') 129 | .addToggle(toggle => 130 | toggle 131 | .setValue(jekyllSetting.isEnableCurlyBraceConvertMode) 132 | .onChange(async value => { 133 | jekyllSetting.isEnableCurlyBraceConvertMode = value; 134 | await this.plugin.saveSettings(); 135 | }), 136 | ); 137 | } 138 | 139 | private addJekyllPathSetting() { 140 | const jekyllSetting = this.plugin.jekyll as JekyllSettings; 141 | new Setting(this.containerEl) 142 | .setName('Jekyll path') 143 | .setDesc('The absolute path where Jekyll workspace is located.') 144 | .addText(text => 145 | text 146 | .setPlaceholder('Enter path') 147 | .setValue(jekyllSetting.jekyllPath) 148 | .onChange(async value => { 149 | jekyllSetting.jekyllPath = value; 150 | await this.plugin.saveSettings(); 151 | }), 152 | ); 153 | } 154 | 155 | private addJekyllRelativeResourcePathSetting() { 156 | const jekyllSetting = this.plugin.jekyll as JekyllSettings; 157 | new Setting(this.containerEl) 158 | .setName('Relative resource path') 159 | .setDesc( 160 | 'The relative path where resources are stored. (default: assets/img)', 161 | ) 162 | .addText(text => 163 | text 164 | .setPlaceholder('Enter path') 165 | .setValue(jekyllSetting.jekyllRelativeResourcePath) 166 | .onChange(async value => { 167 | jekyllSetting.jekyllRelativeResourcePath = value; 168 | await this.plugin.saveSettings(); 169 | }), 170 | ); 171 | } 172 | 173 | private addAttachmentsFolderSetting() { 174 | new Setting(this.containerEl) 175 | .setName('Folder to store attachments in') 176 | .setDesc('Where the attachments will be stored.') 177 | .addText(text => 178 | text 179 | .setPlaceholder('Enter folder name') 180 | .setValue(this.plugin.obsidianPathSettings.attachmentsFolder) 181 | .onChange(async value => { 182 | this.plugin.obsidianPathSettings.attachmentsFolder = value; 183 | await this.plugin.saveSettings(); 184 | }), 185 | ); 186 | } 187 | 188 | private addReadyFolderSetting() { 189 | new Setting(this.containerEl) 190 | .setName('Folder to convert notes to another syntax in') 191 | .setDesc('Where the notes will be converted to another syntax.') 192 | .addText(text => 193 | text 194 | .setPlaceholder('Enter folder name') 195 | .setValue(this.plugin.obsidianPathSettings.readyFolder) 196 | .onChange(async value => { 197 | this.plugin.obsidianPathSettings.readyFolder = value; 198 | await this.plugin.saveSettings(); 199 | }), 200 | ); 201 | } 202 | 203 | private addArchiveFolderSetting() { 204 | new Setting(this.containerEl) 205 | .setName('Folder to Archive notes in') 206 | .setDesc('Where the notes will be archived after conversion.') 207 | .addText(text => 208 | text 209 | .setPlaceholder('Enter folder name') 210 | .setValue(this.plugin.obsidianPathSettings.archiveFolder) 211 | .onChange(async value => { 212 | this.plugin.obsidianPathSettings.archiveFolder = value; 213 | await this.plugin.saveSettings(); 214 | }), 215 | ); 216 | } 217 | 218 | private addDocusaurusPathSetting() { 219 | const docusaurus = this.plugin.docusaurus as DocusaurusSettings; 220 | new Setting(this.containerEl) 221 | .setName('Docusaurus path') 222 | .setDesc('The absolute path where Docusaurus workspace is located.') 223 | .addText(text => 224 | text 225 | .setPlaceholder('Enter path') 226 | .setValue(docusaurus.docusaurusPath) 227 | .onChange(async value => { 228 | docusaurus.docusaurusPath = value; 229 | await this.plugin.saveSettings(); 230 | }), 231 | ); 232 | } 233 | 234 | private dateExtractionPatternSetting() { 235 | const docusaurus = this.plugin.docusaurus as DocusaurusSettings; 236 | new Setting(this.containerEl) 237 | .setName('Date extraction pattern') 238 | .setDesc('The pattern to extract date from note title.') 239 | .addDropdown(dropdown => { 240 | for (const key in DateExtractionPattern) { 241 | dropdown.addOption(key, DateExtractionPattern[key].pattern); 242 | } 243 | dropdown.setValue(docusaurus.dateExtractionPattern); 244 | dropdown.onChange(async value => { 245 | docusaurus.dateExtractionPattern = value; 246 | await this.plugin.saveSettings(); 247 | }); 248 | }); 249 | } 250 | 251 | private enableAutoArchiveSetting() { 252 | new Setting(this.containerEl) 253 | .setName('Auto archive') 254 | .setDesc('Automatically move files to archive folder after converting.') 255 | .addToggle(toggle => 256 | toggle 257 | .setValue(this.plugin.obsidianPathSettings.isAutoArchive) 258 | .onChange(async value => { 259 | this.plugin.obsidianPathSettings.isAutoArchive = value; 260 | await this.plugin.saveSettings(); 261 | }), 262 | ); 263 | } 264 | 265 | private addJekyllRelativeUrlSetting() { 266 | const jekyllSetting = this.plugin.jekyll as JekyllSettings; 267 | new Setting(this.containerEl) 268 | .setName('Enable relative URL for images') 269 | .setDesc( 270 | "Use Jekyll's relative_url filter for image paths. Required when using baseurl.", 271 | ) 272 | .addToggle(toggle => 273 | toggle 274 | .setValue(jekyllSetting.isEnableRelativeUrl) 275 | .onChange(async value => { 276 | jekyllSetting.isEnableRelativeUrl = value; 277 | await this.plugin.saveSettings(); 278 | }), 279 | ); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/tests/core/converters/FrontMatterConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { convertFrontMatter } from '../../../core/converters/FrontMatterConverter'; 2 | import { ConversionError } from '../../../core/types/types'; 3 | 4 | describe('Functional FrontMatter Converter', () => { 5 | describe('Basic front matter conversion', () => { 6 | it('should handle basic front matter correctly', () => { 7 | const input = `--- 8 | title: test 9 | date: 2021-01-01 12:00:00 +0900 10 | categories: [test] 11 | --- 12 | 13 | # test 14 | `; 15 | const result = convertFrontMatter(input); 16 | expect(result._tag).toBe('Right'); 17 | if (result._tag === 'Right') { 18 | expect(result.value).toEqual(`--- 19 | title: "test" 20 | date: 2021-01-01 12:00:00 +0900 21 | categories: [test] 22 | --- 23 | 24 | # test 25 | `); 26 | } 27 | }); 28 | }); 29 | 30 | describe('Image handling', () => { 31 | it('should process image paths when enabled', () => { 32 | const input = `--- 33 | title: test 34 | image: test.png 35 | ---`; 36 | const result = convertFrontMatter(input, { 37 | fileName: '2023-01-01-test', 38 | resourcePath: 'assets/img', 39 | isEnableBanner: true, 40 | }); 41 | expect(result._tag).toBe('Right'); 42 | if (result._tag === 'Right') { 43 | expect(result.value).toContain( 44 | 'image: /assets/img/2023-01-01-test/test.png', 45 | ); 46 | } 47 | }); 48 | 49 | it('should not process image paths when disabled', () => { 50 | const input = `--- 51 | title: test 52 | image: test.png 53 | ---`; 54 | const result = convertFrontMatter(input, { 55 | fileName: '2023-01-01-test', 56 | resourcePath: 'assets/img', 57 | isEnableBanner: false, 58 | }); 59 | expect(result._tag).toBe('Right'); 60 | if (result._tag === 'Right') { 61 | expect(result.value).toContain('image: test.png'); 62 | } 63 | }); 64 | }); 65 | 66 | describe('Mermaid handling', () => { 67 | it('should add mermaid flag when mermaid code block is present', () => { 68 | const input = `--- 69 | title: test 70 | --- 71 | 72 | \`\`\`mermaid 73 | graph TD 74 | A-->B 75 | \`\`\``; 76 | const result = convertFrontMatter(input); 77 | expect(result._tag).toBe('Right'); 78 | if (result._tag === 'Right') { 79 | expect(result.value).toContain('mermaid: true'); 80 | } 81 | }); 82 | 83 | it('should not add mermaid flag when no mermaid code block', () => { 84 | const input = `--- 85 | title: test 86 | --- 87 | 88 | \`\`\`javascript 89 | console.log('test'); 90 | \`\`\``; 91 | const result = convertFrontMatter(input); 92 | expect(result._tag).toBe('Right'); 93 | if (result._tag === 'Right') { 94 | expect(result.value).not.toContain('mermaid: true'); 95 | } 96 | }); 97 | }); 98 | 99 | describe('Date handling', () => { 100 | it('should update date from updated field when enabled', () => { 101 | const input = `--- 102 | title: test 103 | updated: 2023-01-01 104 | ---`; 105 | const result = convertFrontMatter(input, { 106 | isEnableUpdateFrontmatterTimeOnEdit: true, 107 | }); 108 | expect(result._tag).toBe('Right'); 109 | if (result._tag === 'Right') { 110 | expect(result.value).toContain('date: 2023-01-01'); 111 | expect(result.value).not.toContain('updated:'); 112 | } 113 | }); 114 | 115 | it('should not update date when disabled', () => { 116 | const input = `--- 117 | title: test 118 | updated: 2023-01-01 119 | ---`; 120 | const result = convertFrontMatter(input, { 121 | isEnableUpdateFrontmatterTimeOnEdit: false, 122 | }); 123 | expect(result._tag).toBe('Right'); 124 | if (result._tag === 'Right') { 125 | expect(result.value).toContain('updated: 2023-01-01'); 126 | } 127 | }); 128 | 129 | it('should format Date object correctly', () => { 130 | const date = new Date('2023-01-01'); 131 | const input = `--- 132 | title: test 133 | updated: ${date.toISOString()} 134 | ---`; 135 | const result = convertFrontMatter(input, { 136 | isEnableUpdateFrontmatterTimeOnEdit: true, 137 | }); 138 | expect(result._tag).toBe('Right'); 139 | if (result._tag === 'Right') { 140 | expect(result.value).toContain('date: 2023-01-01'); 141 | } 142 | }); 143 | 144 | it('should handle non-string and non-Date values', () => { 145 | const input = `--- 146 | title: test 147 | updated: 42 148 | ---`; 149 | const result = convertFrontMatter(input, { 150 | isEnableUpdateFrontmatterTimeOnEdit: true, 151 | }); 152 | expect(result._tag).toBe('Right'); 153 | if (result._tag === 'Right') { 154 | expect(result.value).toContain('date: 42'); 155 | } 156 | }); 157 | }); 158 | 159 | describe('Author handling', () => { 160 | it('should format single author correctly', () => { 161 | const input = `--- 162 | title: test 163 | authors: John Doe 164 | ---`; 165 | const result = convertFrontMatter(input, { 166 | authors: 'John Doe', 167 | }); 168 | expect(result._tag).toBe('Right'); 169 | if (result._tag === 'Right') { 170 | expect(result.value).toContain('authors: John Doe'); 171 | } 172 | }); 173 | 174 | it('should format multiple authors correctly', () => { 175 | const input = `--- 176 | title: test 177 | authors: John Doe, Jane Smith 178 | ---`; 179 | const result = convertFrontMatter(input, { 180 | authors: 'John Doe, Jane Smith', 181 | }); 182 | expect(result._tag).toBe('Right'); 183 | if (result._tag === 'Right') { 184 | expect(result.value).toContain('authors: [John Doe, Jane Smith]'); 185 | } 186 | }); 187 | }); 188 | 189 | describe('Error handling', () => { 190 | it('should handle invalid front matter', () => { 191 | const input = `--- 192 | invalid: yaml: : 193 | ---`; 194 | const result = convertFrontMatter(input); 195 | expect(result._tag).toBe('Left'); 196 | if (result._tag === 'Left' && (result.value as ConversionError).type) { 197 | expect((result.value as ConversionError).type).toBe('PARSE_ERROR'); 198 | } 199 | }); 200 | 201 | it('should handle malformed YAML with incorrect indentation', () => { 202 | const input = `--- 203 | title: test 204 | incorrect: 205 | indentation: 206 | - item 207 | ---`; 208 | const result = convertFrontMatter(input); 209 | expect(result._tag).toBe('Left'); 210 | if (result._tag === 'Left') { 211 | expect((result.value as ConversionError).type).toBe('PARSE_ERROR'); 212 | expect((result.value as ConversionError).message).toContain( 213 | 'Failed to parse front matter', 214 | ); 215 | } 216 | }); 217 | 218 | it('should handle malformed YAML with duplicate keys', () => { 219 | const input = `--- 220 | title: first 221 | title: second 222 | ---`; 223 | const result = convertFrontMatter(input); 224 | expect(result._tag).toBe('Left'); 225 | if (result._tag === 'Left') { 226 | expect((result.value as ConversionError).type).toBe('PARSE_ERROR'); 227 | } 228 | }); 229 | 230 | it('should handle malformed YAML with invalid structure', () => { 231 | const input = `--- 232 | [invalid structure 233 | ---`; 234 | const result = convertFrontMatter(input); 235 | expect(result._tag).toBe('Left'); 236 | if (result._tag === 'Left') { 237 | expect((result.value as ConversionError).type).toBe('PARSE_ERROR'); 238 | } 239 | }); 240 | 241 | it('should handle front matter without end marker', () => { 242 | const input = `--- 243 | title: test 244 | content without end marker`; 245 | const result = convertFrontMatter(input); 246 | expect(result._tag).toBe('Right'); 247 | if (result._tag === 'Right') { 248 | expect(result.value).toBe(input); 249 | } 250 | }); 251 | }); 252 | 253 | describe('Tags handling', () => { 254 | it('should handle single tag as string', () => { 255 | const input = `--- 256 | title: test 257 | tags: javascript 258 | ---`; 259 | const result = convertFrontMatter(input); 260 | expect(result._tag).toBe('Right'); 261 | if (result._tag === 'Right') { 262 | expect(result.value).toContain('tags: [javascript]'); 263 | } 264 | }); 265 | 266 | it('should handle multiple tags as array', () => { 267 | const input = `--- 268 | title: test 269 | tags: [javascript, typescript] 270 | ---`; 271 | const result = convertFrontMatter(input); 272 | expect(result._tag).toBe('Right'); 273 | if (result._tag === 'Right') { 274 | expect(result.value).toContain('tags: [javascript, typescript]'); 275 | } 276 | }); 277 | 278 | it('should handle comma-separated tags string', () => { 279 | const input = `--- 280 | title: test 281 | tags: javascript, typescript 282 | ---`; 283 | const result = convertFrontMatter(input); 284 | expect(result._tag).toBe('Right'); 285 | if (result._tag === 'Right') { 286 | expect(result.value).toContain('tags: [javascript, typescript]'); 287 | } 288 | }); 289 | 290 | it('should handle missing tags field', () => { 291 | const input = `--- 292 | title: test 293 | ---`; 294 | const result = convertFrontMatter(input); 295 | expect(result._tag).toBe('Right'); 296 | if (result._tag === 'Right') { 297 | expect(result.value).not.toContain('tags:'); 298 | } 299 | }); 300 | 301 | it('should handle empty tags', () => { 302 | const input = `--- 303 | title: test 304 | tags: 305 | ---`; 306 | const result = convertFrontMatter(input); 307 | expect(result._tag).toBe('Right'); 308 | if (result._tag === 'Right') { 309 | expect(result.value).toContain('tags: null'); 310 | } 311 | }); 312 | }); 313 | 314 | describe('Edge cases', () => { 315 | it('should handle missing front matter', () => { 316 | const input = '# Just content\nNo front matter'; 317 | const result = convertFrontMatter(input); 318 | expect(result._tag).toBe('Right'); 319 | if (result._tag === 'Right') { 320 | expect(result.value).toBe(input); 321 | } 322 | }); 323 | 324 | it('should handle empty front matter', () => { 325 | const input = `--- 326 | --- 327 | Content`; 328 | const result = convertFrontMatter(input); 329 | expect(result._tag).toBe('Right'); 330 | if (result._tag === 'Right') { 331 | expect(result.value).toContain('Content'); 332 | } 333 | }); 334 | 335 | it('should handle front matter with only dividers', () => { 336 | const input = `--- 337 | title: test 338 | --- 339 | # Content 340 | --- 341 | More content`; 342 | const result = convertFrontMatter(input); 343 | expect(result._tag).toBe('Right'); 344 | if (result._tag === 'Right') { 345 | expect(result.value).toContain('More content'); 346 | expect(result.value).toContain('# Content'); 347 | } 348 | }); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /src/tests/core/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileSystemAdapter, 3 | TFile, 4 | Vault, 5 | FileManager, 6 | DataWriteOptions, 7 | TAbstractFile, 8 | EventRef, 9 | } from 'obsidian'; 10 | import { ObsidianPathSettings } from '../../../settings'; 11 | import { 12 | TEMP_PREFIX, 13 | vaultAbsolutePath, 14 | getFilesInReady, 15 | copyMarkdownFile, 16 | archiving, 17 | moveFiles, 18 | cleanUp, 19 | parseLocalDate, 20 | } from '../../../core/utils/utils'; 21 | import { DataAdapter, TFolder } from 'obsidian'; 22 | import fs from 'fs'; 23 | 24 | // Create a minimal mock FileManager 25 | const createMockFileManager = ( 26 | overrides: Partial = {}, 27 | ): FileManager => ({ 28 | getNewFileParent: (sourcePath: string) => ({}) as TFolder, 29 | trashFile: async (file: TFile) => {}, 30 | generateMarkdownLink: () => '', 31 | processFrontMatter: async (file: TFile, fn: (frontmatter: any) => void) => {}, 32 | getAvailablePathForAttachment: async (filename: string, extension: string) => 33 | '', 34 | renameFile: async (file: TFile, newPath: string) => {}, 35 | ...overrides, 36 | }); 37 | 38 | // Create a minimal mock Vault that satisfies the type requirements 39 | const createMockVault = (overrides: Partial = {}): Vault => ({ 40 | adapter: new FileSystemAdapter() as DataAdapter, 41 | configDir: '', 42 | getName: () => '', 43 | getAbstractFileByPath: () => null, 44 | getRoot: () => ({}) as TFolder, 45 | getFileByPath: () => null, 46 | getFolderByPath: () => null, 47 | getMarkdownFiles: () => [], 48 | getAllLoadedFiles: () => [], 49 | getAllFolders: (includeRoot?: boolean) => [], 50 | getFiles: () => [], 51 | copy: async (file: T, newPath: string): Promise => 52 | ({}) as T, 53 | delete: async () => undefined, 54 | create: async () => ({}) as TFile, 55 | createBinary: async () => ({}) as TFile, 56 | createFolder: async (path: string) => ({}) as TFolder, 57 | read: async () => '', 58 | readBinary: async () => new ArrayBuffer(0), 59 | rename: async () => undefined, 60 | modify: async (file: TFile, data: string) => {}, 61 | modifyBinary: async ( 62 | file: TFile, 63 | data: ArrayBuffer, 64 | options?: DataWriteOptions, 65 | ) => {}, 66 | append: async (file: TFile, data: string, options?: DataWriteOptions) => {}, 67 | process: async (file: TFile, fn: (data: string) => string) => fn(''), 68 | getResourcePath: () => '', 69 | cachedRead: async (file: TFile) => '', 70 | on: () => ({ id: 0 }), 71 | off: () => {}, 72 | offref: (ref: EventRef) => {}, 73 | trigger: () => {}, 74 | tryTrigger: () => {}, 75 | trash: async (file: TAbstractFile, system: boolean) => {}, 76 | ...overrides, 77 | }); 78 | 79 | // Create a complete mock ObsidianPathSettings 80 | const createMockSettings = ( 81 | overrides: Partial = {}, 82 | ): ObsidianPathSettings => ({ 83 | readyFolder: 'ready', 84 | archiveFolder: 'archive', 85 | attachmentsFolder: 'attachments', 86 | isAutoArchive: false, 87 | isAutoCreateFolder: false, 88 | ...overrides, 89 | }); 90 | 91 | jest.mock('fs', () => ({ 92 | mkdirSync: jest.fn(), 93 | copyFileSync: jest.fn(), 94 | readdirSync: jest.fn(), 95 | existsSync: jest.fn(), 96 | })); 97 | 98 | jest.mock( 99 | 'obsidian', 100 | () => { 101 | class MockFileSystemAdapter { 102 | getBasePath = jest.fn(); 103 | } 104 | return { 105 | FileSystemAdapter: MockFileSystemAdapter, 106 | Notice: jest.fn(), 107 | TFile: jest.fn(), 108 | }; 109 | }, 110 | { virtual: true }, 111 | ); 112 | 113 | describe('utils', () => { 114 | beforeEach(() => { 115 | jest.clearAllMocks(); 116 | }); 117 | 118 | describe('vaultAbsolutePath', () => { 119 | it('should return base path when adapter is FileSystemAdapter', () => { 120 | const mockBasePath = '/test/path'; 121 | const { FileSystemAdapter } = jest.requireMock('obsidian'); 122 | const mockAdapter = new FileSystemAdapter(); 123 | mockAdapter.getBasePath.mockReturnValue(mockBasePath); 124 | 125 | const mockPlugin = { 126 | app: { 127 | vault: createMockVault({ 128 | adapter: mockAdapter, 129 | }), 130 | fileManager: createMockFileManager(), 131 | }, 132 | }; 133 | 134 | const result = vaultAbsolutePath(mockPlugin); 135 | expect(result).toBe(mockBasePath); 136 | expect(mockAdapter.getBasePath).toHaveBeenCalled(); 137 | }); 138 | 139 | it('should throw error when adapter is not FileSystemAdapter', () => { 140 | const mockPlugin = { 141 | app: { 142 | vault: createMockVault({ 143 | adapter: {} as FileSystemAdapter, 144 | }), 145 | fileManager: createMockFileManager(), 146 | }, 147 | }; 148 | 149 | expect(() => vaultAbsolutePath(mockPlugin)).toThrow( 150 | 'Vault is not a file system adapter', 151 | ); 152 | }); 153 | }); 154 | 155 | describe('getFilesInReady', () => { 156 | it('should return markdown files from ready folder', () => { 157 | const mockFiles = [ 158 | { path: 'ready/test1.md' }, 159 | { path: 'ready/test2.md' }, 160 | { path: 'other/test3.md' }, 161 | ] as TFile[]; 162 | 163 | const mockPlugin = { 164 | app: { 165 | vault: createMockVault({ 166 | getMarkdownFiles: jest.fn().mockReturnValue(mockFiles), 167 | }), 168 | fileManager: createMockFileManager(), 169 | }, 170 | obsidianPathSettings: createMockSettings(), 171 | }; 172 | 173 | const result = getFilesInReady(mockPlugin); 174 | expect(result).toHaveLength(2); 175 | expect(result[0].path).toBe('ready/test1.md'); 176 | expect(result[1].path).toBe('ready/test2.md'); 177 | }); 178 | 179 | it('should return empty array when no files in ready folder', () => { 180 | const mockFiles = [ 181 | { path: 'other/test1.md' }, 182 | { path: 'other/test2.md' }, 183 | ] as TFile[]; 184 | 185 | const mockPlugin = { 186 | app: { 187 | vault: createMockVault({ 188 | getMarkdownFiles: jest.fn().mockReturnValue(mockFiles), 189 | }), 190 | fileManager: createMockFileManager(), 191 | }, 192 | obsidianPathSettings: createMockSettings(), 193 | }; 194 | 195 | const result = getFilesInReady(mockPlugin); 196 | expect(result).toHaveLength(0); 197 | }); 198 | }); 199 | 200 | describe('copyMarkdownFile', () => { 201 | it('should copy files and return temp files', async () => { 202 | const mockFiles = [ 203 | { path: 'ready/test1.md', name: 'test1.md' }, 204 | { path: 'ready/test2.md', name: 'test2.md' }, 205 | ] as TFile[]; 206 | 207 | const mockPlugin = { 208 | app: { 209 | vault: createMockVault({ 210 | getMarkdownFiles: jest 211 | .fn() 212 | .mockReturnValueOnce(mockFiles) 213 | .mockReturnValueOnce([ 214 | ...mockFiles, 215 | { 216 | path: `ready/${TEMP_PREFIX}test3.md`, 217 | name: `${TEMP_PREFIX}test3.md`, 218 | }, 219 | ]), 220 | copy: jest.fn().mockResolvedValue(undefined), 221 | }), 222 | fileManager: createMockFileManager(), 223 | }, 224 | obsidianPathSettings: createMockSettings(), 225 | }; 226 | 227 | const result = await copyMarkdownFile(mockPlugin); 228 | expect(result).toHaveLength(1); 229 | expect(result[0].path).toContain(TEMP_PREFIX); 230 | expect(mockPlugin.app.vault.copy).toHaveBeenCalledTimes(2); 231 | }); 232 | 233 | it('should handle copy errors gracefully', async () => { 234 | const mockFiles = [ 235 | { path: 'ready/test1.md', name: 'test1.md' }, 236 | ] as TFile[]; 237 | 238 | const mockPlugin = { 239 | app: { 240 | vault: createMockVault({ 241 | getMarkdownFiles: jest.fn().mockReturnValue(mockFiles), 242 | copy: jest.fn().mockRejectedValue(new Error('Copy failed')), 243 | }), 244 | fileManager: createMockFileManager(), 245 | }, 246 | obsidianPathSettings: createMockSettings(), 247 | }; 248 | 249 | const consoleSpy = jest.spyOn(console, 'error'); 250 | await copyMarkdownFile(mockPlugin); 251 | expect(consoleSpy).toHaveBeenCalledWith(new Error('Copy failed')); 252 | consoleSpy.mockRestore(); 253 | }); 254 | }); 255 | 256 | describe('archiving', () => { 257 | it('should not archive when isAutoArchive is false', async () => { 258 | const mockFileManager = createMockFileManager({ 259 | renameFile: jest.fn(), 260 | }); 261 | 262 | const mockPlugin = { 263 | app: { 264 | vault: createMockVault(), 265 | fileManager: mockFileManager, 266 | }, 267 | obsidianPathSettings: createMockSettings(), 268 | }; 269 | 270 | await archiving(mockPlugin); 271 | expect(mockFileManager.renameFile).not.toHaveBeenCalled(); 272 | }); 273 | 274 | it('should move files to archive folder when isAutoArchive is true', async () => { 275 | const mockFiles = [ 276 | { path: 'ready/test1.md' }, 277 | { path: 'ready/test2.md' }, 278 | ] as TFile[]; 279 | 280 | const mockFileManager = createMockFileManager({ 281 | renameFile: jest.fn(), 282 | }); 283 | 284 | const mockPlugin = { 285 | app: { 286 | vault: createMockVault({ 287 | getMarkdownFiles: jest.fn().mockReturnValue(mockFiles), 288 | }), 289 | fileManager: mockFileManager, 290 | }, 291 | obsidianPathSettings: createMockSettings({ 292 | isAutoArchive: true, 293 | }), 294 | }; 295 | 296 | await archiving(mockPlugin); 297 | expect(mockFileManager.renameFile).toHaveBeenCalledTimes(2); 298 | expect(mockFileManager.renameFile).toHaveBeenCalledWith( 299 | mockFiles[0], 300 | 'archive/test1.md', 301 | ); 302 | }); 303 | }); 304 | 305 | describe('moveFiles', () => { 306 | it('should move files to target directory', async () => { 307 | const sourcePath = '/source'; 308 | const targetPath = '/target'; 309 | const mockFiles = [ 310 | 'o2-temp.2024-03-21-test1.md', 311 | 'o2-temp.2024-03-21-test2.md', 312 | ]; 313 | 314 | (fs.readdirSync as jest.Mock).mockReturnValue(mockFiles); 315 | (fs.existsSync as jest.Mock).mockReturnValue(true); 316 | 317 | const pathReplacer = ( 318 | year: string, 319 | month: string, 320 | day: string, 321 | title: string, 322 | ) => `${year}-${month}-${day}-${title}.md`; 323 | 324 | await moveFiles(sourcePath, targetPath, pathReplacer); 325 | 326 | expect(fs.copyFileSync).toHaveBeenCalledTimes(2); 327 | expect(fs.mkdirSync).not.toHaveBeenCalled(); 328 | }); 329 | 330 | it('should create target directory if it does not exist', async () => { 331 | const sourcePath = '/source'; 332 | const targetPath = '/target'; 333 | const mockFiles = ['o2-temp.2024-03-21-test1.md']; 334 | 335 | (fs.readdirSync as jest.Mock).mockReturnValue(mockFiles); 336 | (fs.existsSync as jest.Mock).mockReturnValue(false); 337 | 338 | const pathReplacer = ( 339 | year: string, 340 | month: string, 341 | day: string, 342 | title: string, 343 | ) => `${year}-${month}-${day}-${title}.md`; 344 | 345 | await moveFiles(sourcePath, targetPath, pathReplacer); 346 | 347 | expect(fs.mkdirSync).toHaveBeenCalledWith(targetPath, { 348 | recursive: true, 349 | }); 350 | expect(fs.copyFileSync).toHaveBeenCalledTimes(1); 351 | }); 352 | }); 353 | 354 | describe('cleanUp', () => { 355 | it('should delete temporary files', async () => { 356 | const mockTempFiles = [ 357 | { path: `ready/${TEMP_PREFIX}test1.md` }, 358 | { path: `ready/${TEMP_PREFIX}test2.md` }, 359 | ] as TFile[]; 360 | 361 | const mockPlugin = { 362 | app: { 363 | vault: createMockVault({ 364 | getMarkdownFiles: jest.fn().mockReturnValue(mockTempFiles), 365 | delete: jest.fn().mockResolvedValue(undefined), 366 | }), 367 | fileManager: createMockFileManager(), 368 | }, 369 | }; 370 | 371 | const consoleSpy = jest.spyOn(console, 'log'); 372 | await cleanUp(mockPlugin); 373 | 374 | expect(mockPlugin.app.vault.delete).toHaveBeenCalledTimes(2); 375 | expect(consoleSpy).toHaveBeenCalledTimes(2); 376 | expect(consoleSpy).toHaveBeenCalledWith( 377 | `Deleted temp file: ready/${TEMP_PREFIX}test1.md`, 378 | ); 379 | consoleSpy.mockRestore(); 380 | }); 381 | }); 382 | 383 | describe('parseLocalDate', () => { 384 | it('should parse valid date string', () => { 385 | const result = parseLocalDate('2024-03-21'); 386 | expect(result).toEqual({ 387 | year: '2024', 388 | month: '03', 389 | day: '21', 390 | }); 391 | }); 392 | 393 | it('should throw error for invalid date format', () => { 394 | expect(() => parseLocalDate('invalid-date')).toThrow( 395 | 'Invalid date format', 396 | ); 397 | expect(() => parseLocalDate('2024/03/21')).toThrow('Invalid date format'); 398 | expect(() => parseLocalDate('24-3-21')).toThrow('Invalid date format'); 399 | }); 400 | }); 401 | }); 402 | --------------------------------------------------------------------------------