Repository: VTimofeenko/confluence_poster (language: python)

Issue:
Implement filter mode
To allow for fully unattended mode, the script could read the page text from STDIN and post it. This would allow the user to post an article without leaving the editor by piping the current file into script.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 7872a0e..5ee032f 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,62 @@
 # Description
 
-Supplementary script for writing confluence wiki articles in
-vim. Uses information from the config to post the article content to confluence.
+Supplementary script for writing Confluence articles in
+local editor. Uses information from the config to post the article content to confluence.
+
+May be used either on its own:
+
+    $ confluence_poster post-page
+
+As a filter:
+
+    $ cat file.md | confluence_poster --file-format markdown post-page
+
+# Getting started
+
+## Installation
+
+1. Install the project from PyPI:
+
+    ```console
+    $ pip install confluence-poster
+    ```
+
+2. Create the config manually ([sample available in repo](https://github.com/VTimofeenko/confluence_poster/blob/master/config.toml)) or run `confluence_poster create-config` to run a wizard
+
+## Sample usage
+
+Given the following files in the current directory:
+
+```
+├── attachment1.docx
+├── attachment2.png
+├── poster_config.toml
+└── page1.md
+```
+
+`poster_config.toml` has:
+
+```toml
+[pages]
+[pages.page1]
+page_title = "Some page"
+page_file = "page1.md"
+page_space = "SPACE"
+```
+
+config inside `${HOME}/.config/confluence_poster/` has the authentication information and the Confluence URL.
+
+Running
+
+```console
+$ confluence_poster --config poster_config.toml post-page --upload-files attachment1.docx attachment2.png
+```
+
+will attempt to locate the page on Confluence instance, update its content to the text in `page1.md` and attach the files to it.
+
+If the script cannot locate it, it will prompt the user to create it, optionally under a parent page.
+
+# Details
 
 **Usage**:
 
@@ -11,16 +66,18 @@ $ confluence_poster [OPTIONS] COMMAND [ARGS]...
 
 **General Options**:
 
-* `--version`: Show version and exit
+* `--version`: Show version and exit.
 * `--config PATH`: The file containing configuration. If not specified - config.toml from the same directory is used  [default: config.toml]
 * `--page-title TEXT`: Override page title from config. Applicable if there is only one page.
 * `--parent-page-title TEXT`: Provide a parent title to search for. Applicable if there is only one page.
+* `--page-file PATH`: Provide the path to the file containing page text. Allows passing '-' to read from stdin.
 * `--password TEXT`: Supply the password in command line.  [env var: CONFLUENCE_PASSWORD]
-* `--force`: Force overwrite the pages. Skips all checks for different author of the updated page. To set for individual pages you can specify field 'force_overwrite' in config
+* `--force`: Force overwrite the pages. Skips all checks for different author of the updated page. To set for individual pages you can specify field 'force_overwrite' in config.
 * `--force-create`: Disable prompts to create pages. Script could still prompt for a parent page.
 * `--minor-edit`: Do not notify watchers of pages updates. Not enabled by default.
 * `--report`: Print report at the end of the run. Not enabled by default.
 * `--debug`: Enable debug logging. Not enabled by default.
+* `--quiet`: Suppresses certain output.
 * `--install-completion`: Install completion for the current shell.
 * `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
 * `--help`: Show this message and exit.
@@ -29,6 +86,7 @@ These options can be specified for any `COMMAND` except for  `create-config` whi
 
 **Commands**:
 
+* `convert-markdown`: Converts single page text from markdown to...
 * `create-config`: Runs configuration wizard.
 * `post-page`: Posts the content of the pages.
 * `validate`: Validates the provided settings.
@@ -46,8 +104,10 @@ $ confluence_poster post-page [OPTIONS] [FILES]...
 
 **Options**:
 
-* `--upload-files`: Upload list of files
+* `--upload-files`: Upload list of files.
 * `--version-comment TEXT`: Provider version comment.
+* `--create-in-space-root`: Create the page in space root.
+* `--file-format [confluencewiki|markdown|html|None]`: File format of the file with the page content. If provided at runtime - can only be applied to a single page. If set to 'None'(default) - script will try to guess it during the run.
 * `--help`: Show this message and exit.
 
 ## `confluence_poster validate`
@@ -72,25 +132,15 @@ Runs configuration wizard. The wizard guides through setting up values for confi
 
 **Options**:
 
-* `--local-only`: Create config only in the local folder  [default: False]
-* `--home-only`: Create config only in the $XDG_CONFIG_HOME  [default: False]
+* `--local-only`: Create config only in the local folder.
+* `--home-only`: Create config only in the $XDG_CONFIG_HOME.
 * `--help`: Show this message and exit.
 
-# Installation
-
-Install the project from PyPI:
-
-```console
-$ pip install confluence-poster
-```
-
-To start using `confluence_poster` either create the config manually or run `confluence_poster create-config` to start
-configuration wizard which will guide you through the configuration.
 
 # Config format
 
 By default the confluence_poster tries to look for config file `config.toml` in the directory where it is invoked and in
-XDG_CONFIG_HOME. The config format is as follows:
+$XDG_CONFIG_HOME. The config format is as follows:
 
 ```toml
 # If the page was not updated by the username specified here, throw an error.
@@ -113,6 +163,8 @@ page_space = "some_space_key"
 force_overwrite = false
 # If specified - the page will be created without looking for a parent under specified parent
 page_parent_title = "Parent page title"
+# If specified - script will convert the text in the file before posting it. If not specified - script will try to guess it based on file extension.
+page_file_format = "confluencewiki"
 
 [pages.page2]
 page_title = "Some other page title"
@@ -133,9 +185,18 @@ is_cloud = false
 **Note on password and Cloud instances**: if confluence is hosted by Atlassian, the password is the API token.
 Follow instructions at [this link](https://confluence.atlassian.com/cloud/api-tokens-938839638.html).
 
+# Text formats
+
+confluence_poster supports the following formats for posting pages:
+* [Confluencewiki](https://confluence.atlassian.com/doc/confluence-wiki-markup-251003035.html)
+* Markdown
+* Html
+
+The format may be specified explicitly in the config file, passed during the runtime, or the script will try to guess it by the file extension.
+
 # Contrib directory
 
-There are shell completions for bash and zsh as well as a sample of
+There are shell completions for bash and zsh(generated through [typer](typer.tiangolo.com/)) as well as a sample of
 [git post-commit hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks).
 
 # See also
diff --git a/config.toml b/config.toml
index 413f40f..c94db83 100644
--- a/config.toml
+++ b/config.toml
@@ -18,6 +18,8 @@ page_space = "some_space_key"
 force_overwrite = false
 # If specified - the page will be created without looking for a parent under specified parent
 page_parent_title = "Parent page title"
+# If specified - script will convert the text in the file before posting it. If not specified - script will try to guess it based on file extension.
+page_file_format = "confluencewiki"
 
 [pages.page2]
 page_title = "Some other page title"
diff --git a/confluence_poster/config_wizard.py b/confluence_poster/config_wizard.py
index 27ede74..9513ae3 100644
--- a/confluence_poster/config_wizard.py
+++ b/confluence_poster/config_wizard.py
@@ -1,4 +1,5 @@
 import typer
+from click import Choice
 from pathlib import Path
 from functools import reduce
 from typing import Union, Any, Tuple, FrozenSet, Iterable, Callable
@@ -8,6 +9,8 @@
 from dataclasses import dataclass
 from copy import deepcopy
 
+from confluence_poster.poster_config import AllowedFileFormat
+
 
 @dataclass
 class DialogParameter:
@@ -15,7 +18,7 @@ class DialogParameter:
 
     title: str
     comment: Union[str, None] = None  # for passing a comment
-    type: Union[type, None] = None  # for passing type to input
+    type: Union[type, None, Choice] = None  # for passing type to input
     required: bool = True
     hide_input: bool = False
 
@@ -256,6 +259,33 @@ def _generate_next_page(filename: Union[Path, str]) -> int:
             page_number = page_number + 1
 
 
+def generate_page_dialog_params(
+    page_no: int,
+) -> Tuple[DialogParameter, DialogParameter, DialogParameter, DialogParameter]:
+    # noinspection PyUnresolvedReferences
+    return (
+        DialogParameter(
+            title=f"pages.page{page_no}.page_title", comment="The title of the page"
+        ),
+        DialogParameter(
+            title=f"pages.page{page_no}.page_file", comment="File containing page text"
+        ),
+        DialogParameter(
+            title=f"pages.page{page_no}.page_file_format",
+            comment="Text format of the page file. None or default - the script will try to guess it at runtime.",
+            type=Choice(
+                [_[0] for _ in AllowedFileFormat.__members__.items()]
+            ),  # making the linter happy, see JetBrains PY-36205
+            required=False,
+        ),
+        DialogParameter(
+            title=f"pages.page{page_no}.page_space",
+            comment="Key of the space with the page",
+            required=False,
+        ),
+    )
+
+
 def page_add_dialog(
     filename: Union[Path, str], config_print_function=lambda _: print(_)
 ) -> bool:
@@ -264,11 +294,7 @@ def page_add_dialog(
     page_number = _generate_next_page(filename)
     return config_dialog(
         filename,
-        [
-            f"pages.page{page_number}.page_title",
-            f"pages.page{page_number}.page_file",
-            DialogParameter(f"pages.page{page_number}.page_space", required=False),
-        ],
+        attributes=generate_page_dialog_params(page_number),
         config_print_function=config_print_function,
         incremental=True,
     )
diff --git a/confluence_poster/convert_utils.py b/confluence_poster/convert_utils.py
new file mode 100644
index 0000000..d3bc23e
--- /dev/null
+++ b/confluence_poster/convert_utils.py
@@ -0,0 +1,74 @@
+from enum import Enum
+from pathlib import Path
+from atlassian import Confluence
+from requests import Response
+from markdown import markdown
+
+from confluence_poster.poster_config import AllowedFileFormat
+
+
+def post_to_convert_api(confluence: Confluence, text: str) -> str:
+    url = "rest/tinymce/1/markdownxhtmlconverter"
+    # the endpoint returns plain text, need to redefine the default header
+    headers = {"Content-Type": "application/json"}
+    # Keep until https://github.com/atlassian-api/atlassian-python-api/pull/684 is released
+    original_advanced_mode = confluence.advanced_mode
+    if confluence.advanced_mode is False or confluence.advanced_mode is None:
+        confluence.advanced_mode = True
+
+    response: Response = confluence.post(url, data={"wiki": text}, headers=headers)
+    # No way to trigger failure for this during tests
+    response.raise_for_status()  # pragma: no cover
+
+    confluence.advanced_mode = original_advanced_mode
+
+    return response.text
+
+
+def convert_using_markdown_lib(text: str) -> str:
+    return markdown(text, extensions=("tables", "fenced_code"))
+
+
+def guess_file_format(page_file: str) -> AllowedFileFormat:
+    """Attempts to guess the file format from the page file by the file extension.
+    If the extension is unknown raises an error"""
+    md_file_formats = {
+        ".markdown",
+        ".mdown",
+        ".mkdn",
+        ".md",
+        ".mkd",
+        ".mdwn",
+        ".mdtxt",
+        ".mdtext",
+        ".text",
+        ".Rmd",
+    }
+    cw_file_formats = {".confluencewiki", ".wiki"}
+    html_file_formats = {".html"}
+    if (suffix := Path(page_file).suffix) in md_file_formats:
+        return AllowedFileFormat.markdown
+    elif suffix in cw_file_formats:
+        return AllowedFileFormat.confluencewiki
+    elif suffix in html_file_formats:
+        return AllowedFileFormat.html
+    else:
+        raise ValueError(f"File format of file {page_file} could not be guessed.")
+
+
+class Representation(Enum):
+    wiki = "wiki"
+    editor = "editor"
+
+
+def get_representation_for_format(file_format: AllowedFileFormat) -> Representation:
+    if file_format == AllowedFileFormat.markdown:
+        return Representation.editor
+    elif file_format == AllowedFileFormat.html:
+        return Representation.editor
+    elif file_format == AllowedFileFormat.confluencewiki:
+        return Representation.wiki
+    else:
+        raise ValueError(
+            f"Could not determine representation value for {file_format}. This is probably a bug."
+        )
diff --git a/confluence_poster/file_upload_helpers.py b/confluence_poster/file_upload_helpers.py
new file mode 100644
index 0000000..32fc0b6
--- /dev/null
+++ b/confluence_poster/file_upload_helpers.py
@@ -0,0 +1,21 @@
+from typing import Iterable
+from pathlib import Path
+
+from confluence_poster.main_helpers import StateConfig, PostedPage
+
+
+def attach_files_to_page(
+    page: PostedPage, files: Iterable[Path], state: StateConfig
+) -> None:
+    echo = state.print_function
+    always_echo = state.always_print_function
+
+    always_echo("Uploading the files")
+    for path in files:
+        if path.is_file():
+            echo(f"\tUploading file {path.name}...")
+            state.confluence_instance.attach_file(
+                str(path), name=path.name, page_id=str(page.page_id)
+            )
+            echo(f"\tUploaded file {path.name}.")
+    always_echo("Done uploading files")
diff --git a/confluence_poster/main.py b/confluence_poster/main.py
index 6016dbf..271b112 100644
--- a/confluence_poster/main.py
+++ b/confluence_poster/main.py
@@ -1,16 +1,31 @@
 import typer
+import sys
 from click import Choice
-from typing import Optional, List, Union, Tuple
+from typing import Optional, List, Tuple
 from pathlib import Path
 from logging import basicConfig, DEBUG
-from confluence_poster.poster_config import Config, Page
-from confluence_poster.config_loader import load_config
-from confluence_poster.config_wizard import DialogParameter
 from atlassian import Confluence
 from atlassian.errors import ApiError
 from dataclasses import dataclass, field, astuple
 from requests.exceptions import ConnectionError
-from confluence_poster.main_helpers import check_last_updated_by, PostedPage
+
+from confluence_poster.poster_config import Page, AllowedFileFormat
+from confluence_poster.config_loader import load_config
+from confluence_poster.config_wizard import DialogParameter, generate_page_dialog_params
+from confluence_poster.main_helpers import (
+    check_last_updated_by,
+    PostedPage,
+    StateConfig,
+    get_page_url,
+)
+from confluence_poster.convert_utils import (
+    guess_file_format,
+    get_representation_for_format,
+    post_to_convert_api,
+    convert_using_markdown_lib,
+)
+from confluence_poster.page_creation_helpers import create_page
+from confluence_poster.file_upload_helpers import attach_files_to_page
 
 __version__ = "1.3.0"
 default_config_name = "config.toml"
@@ -22,32 +37,6 @@ def version_callback(value: bool):
         raise typer.Exit()
 
 
-def get_page_url(
-    page_title: str, space: str, confluence: Confluence
-) -> Union[str, None]:
-    """Retrieves page URL"""
-    if page := confluence.get_page_by_title(space=space, title=page_title, expand=""):
-        # according to Atlassian REST API reference, '_links' is a legitimate way to access links
-        page_link = confluence.url + page["_links"]["webui"]
-        return page_link
-    else:
-        return None
-
-
-@dataclass
-class StateConfig:
-    """Holds the shared state between typer commands"""
-
-    force: bool = False
-    debug: bool = False
-    confluence_instance: Union[None, Confluence] = None
-    config: Union[None, Config] = None
-    minor_edit: bool = False
-    print_report: bool = False
-    force_create: bool = False
-    created_pages: List[int] = field(default_factory=list)
-
-
 @dataclass
 class Report:
     created_pages: List[Page] = field(default_factory=list)
@@ -77,86 +66,52 @@ def __str__(self) -> str:
         return output
 
 
-def create_page(page: Page, confluence: Confluence) -> (bool, Union[int, None]):
-    """Handles user input for page creation. Returns True and page_id if the page is created."""
+app = typer.Typer()
+state = StateConfig()
 
-    def find_parent(_parent_name: str, space: str) -> Union[int, None]:
-        """Helper function to locate the parent page. Returns page id. Returns None if page is not found"""
-        typer.echo(f"Looking for the parent page with title {_parent_name}")
-        if parent_page := confluence.get_page_by_title(
-            space=space, title=_parent_name, expand=""
-        ):
-            # according to Atlassian REST API reference, '_links' is a legitimate way to access links
-            parent_link = get_page_url(_parent_name, space, confluence)
-            _parent_id = parent_page["id"]
-            typer.echo(
-                f"Found page #{_parent_id}, called {_parent_name}. URL is:\n{parent_link}"
-            )
-            return _parent_id
-        else:
-            typer.echo(f"Parent page '{_parent_name}' not found")
-            return None
-
-    # Page does not exist. Confluence API reports it itself
-    typer.echo(f"Page '{page.page_title}' not found")
-    parent_id = None
-    if state.force_create or typer.confirm("Should it be created?", default=True):
-        if not page.parent_page_title:
-            while typer.confirm(
-                f"Should the script look for a parent in space {page.page_space}?"
-                f" (N to be prompted to create the page in the space root)"
-            ):
-                parent_name = typer.prompt("Which page should the script look for?")
-                if parent_id := find_parent(parent_name, page.page_space):
-                    if typer.confirm(f"Proceed to create?"):
-                        break
-                    else:
-                        return False, None
-            else:
-                # If _parent_id stays None, page will be created in the root
-                if not typer.confirm(
-                    f"Create the page in the root of {page.page_space}? N will skip the page"
-                ):
-                    return False, None
-        else:
-            typer.echo(
-                f"Creating under the specified parent page {page.parent_page_title}"
-            )
-            parent_id = find_parent(page.parent_page_title, page.page_space)
-            if parent_id is None:
-                typer.echo(
-                    f"Parent page '{page.parent_page_title}' not found in space '{page.page_space}'. "
-                    f"Skipping page."
-                )
-                return False, None
 
-        with open(page.page_file, "r") as _:
-            typer.echo("Creating page")
+@app.command()
+def convert_markdown(
+    use_confluence_converter: Optional[bool] = typer.Option(
+        False,
+        "--use-confluence-converter",
+        show_default=False,
+        help="Use built-in Confluence converter. Note: uses Confluence private API.",
+    )
+):
+    """Converts single page text from markdown to html representation (aka "editor"). Prints the converted text.
+    Implies running the utility with --quiet. Logs runtime info only to stderr.
 
-            response = confluence.create_page(
-                space=page.page_space,
-                title=page.page_title,
-                body=_.read(),
-                parent_id=parent_id,
-                representation="wiki",
-            )
-            page_id = response["id"]
-            typer.echo(
-                f"Created page #{page_id} in space {page.page_space} called '{page.page_title}'"
-            )
-            return True, page_id
-    else:
-        return False, None
+    By default uses python's markdown library with fenced code and tables extensions to render markdown to html.
 
+    If --use-confluence-converter flag is used - uses Confluence built-in converter."""
+    confluence = state.confluence_instance
+    always_echo = state.always_print_function
+    echo_err = state.print_stderr
+    text = state.config.pages[0].page_text
+
+    if len(state.config.pages) > 1:
+        echo_err("This command supports converting only one page at a time.")
+        raise typer.Exit(1)
+
+    if use_confluence_converter:
+        echo_err(
+            "Using the converter built into Confluence which is labeled as private API. "
+            "The results may be less than satisfactory."
+        )
+        always_echo(post_to_convert_api(confluence, text))
+    else:
+        always_echo(convert_using_markdown_lib(text))
 
-app = typer.Typer()
-state = StateConfig()
+    echo_err(
+        "Submit the converted text using `confluence_poster post-page --file-format html`.",
+    )
 
 
 @app.command()
 def post_page(
     upload_files: Optional[bool] = typer.Option(
-        False, "--upload-files", show_default=False, help="Upload list of files"
+        False, "--upload-files", show_default=False, help="Upload list of files."
     ),
     version_comment: Optional[str] = typer.Option(
         None,
@@ -164,16 +119,36 @@ def post_page(
         show_default=False,
         help="Provider version comment.",
     ),
+    create_in_space_root: Optional[bool] = typer.Option(
+        False,
+        "--create-in-space-root",
+        show_default=False,
+        help="Create the page in space root.",
+    ),
+    file_format: Optional[AllowedFileFormat] = typer.Option(
+        AllowedFileFormat.none,
+        "--file-format",
+        show_default=False,
+        help="File format of the file with the page content. "
+        "If provided at runtime - can only be applied to a single page. "
+        "If set to 'None'(default) - script will try to guess it during the run.",
+    ),
     files: Optional[List[Path]] = typer.Argument(None, help="List of files to upload"),
 ):
     """Posts the content of the pages."""
+    echo = state.print_function
+    always_echo = state.always_print_function
+    echo_err = state.print_stderr
+    confirm = state.confirm_function
+    prompt = state.prompt_function
+
     report = Report(confluence_instance=state.confluence_instance)
     confluence = state.confluence_instance
     posted_pages = [PostedPage(*astuple(_)) for _ in state.config.pages]
     target_page = posted_pages[0]
 
     if len(posted_pages) > 1 and version_comment is not None:
-        apply_version_comment_to = typer.prompt(
+        apply_version_comment_to = prompt(
             text=f"Multiple pages specified. Do you want to apply the comment to [A]ll pages, "
             "[F]irst one or [N]ot apply it?",
             type=Choice(choices=["A", "F", "N"], case_sensitive=False),
@@ -191,27 +166,56 @@ def post_page(
         # set the comment for the single page
         posted_pages[0].version_comment = version_comment
 
+    # file format check
+    if len(posted_pages) > 1 and file_format is not AllowedFileFormat.none:
+        echo_err(
+            "File format cannot be used for cases when there are more than 1 pages in the config. "
+            "Consider adding it to the config file."
+        )
+        raise typer.Exit(1)
+
+    posted_pages[0].page_file_format = file_format
+
     if upload_files:
         if len(posted_pages) > 1:
-            typer.echo(
+            always_echo(
                 "Upload files are specified, but there are more than 1 pages in the config."
             )
-            if typer.confirm(
-                f"Continue by attaching all files to the first page, '{target_page.page_title}'?",
+            if confirm(
+                f"Continue by attaching all files to the first page, '{target_page.page_title}'? ('N') to abort",
                 default=False,
             ):
                 pass
             else:
-                typer.echo("Aborting.")
-                raise typer.Exit(1)
+                echo_err("Aborting.")
+                raise typer.Exit(3)
 
     for page in posted_pages:
-        typer.echo(f"Looking for page '{page.page_title}'")
+        if page.page_file_format is AllowedFileFormat.none:
+            echo(
+                f"File format for page {page.page_title} not specified. Trying to determine it..."
+            )
+            try:
+                guessed_format = guess_file_format(page.page_file)
+            except ValueError as e:
+                echo_err(
+                    "Could not guess the file format. Consider specifying it manually. "
+                    "See --help for information.",
+                )
+                raise e
+            echo(f"Guessed file format as {guessed_format.value}")
+            page.page_file_format = guessed_format
+
+        if page.page_file_format is AllowedFileFormat.markdown:
+            page.page_text = convert_using_markdown_lib(page.page_text)
+
+        echo(f"Looking for page '{page.page_title}'")
         if page_id := confluence.get_page_id(
             space=page.page_space, title=page.page_title
         ):
             # Page exists
-            typer.echo(f"Found page id #{page_id}")
+            echo(f"Found page id #{page_id}")
+            page.page_id = page_id
 
             # If --force is supplied - we do not really care about who edited the page last
             if not (state.force or page.force_overwrite):
@@ -221,60 +225,55 @@ def post_page(
                     confluence_instance=confluence,
                 )
                 if not updated_by_author:
-                    typer.echo(
+                    echo(
                         f"Flag 'force' is not set and last author of page '{page.page_title}'"
                         f" is {page_last_updated_by}, not {state.config.author}. Skipping page"
                     )
                     continue
             else:
                 if state.force:
-                    typer.echo("Flag 'force' set globally.")
+                    echo("Flag 'force' set globally.")
                 elif page.force_overwrite:
-                    typer.echo("Flag 'force overwrite' set on the page.")
-                typer.echo("Author name check skipped.")
+                    echo("Flag 'force overwrite' set on the page.")
+                echo("Author name check skipped.")
 
-            with open(page.page_file, "r") as _:
-                typer.echo(f"Updating page #{page_id}")
-                confluence.update_existing_page(
-                    page_id=page_id,
-                    title=page.page_title,
-                    body=_.read(),
-                    representation="wiki",
-                    minor_edit=state.minor_edit,
-                    version_comment=page.version_comment,
-                )
-                report.updated_pages += [page]
+            echo(f"Updating page #{page_id}")
+            confluence.update_existing_page(
+                page_id=page_id,
+                title=page.page_title,
+                body=page.page_text,
+                representation=get_representation_for_format(
+                    page.page_file_format
+                ).value,
+                minor_edit=state.minor_edit,
+                version_comment=page.version_comment,
+            )
+            report.updated_pages += [page]
         else:
-            page_was_created, page_id = create_page(page=page, confluence=confluence)
-            if page_was_created:
+            echo(
+                f"Could not find page '{page.page_title}' in space '{page.page_space}'"
+            )
+            if page_created := create_page(
+                page=page, state=state, create_in_root=create_in_space_root
+            ):
                 report.created_pages += [page]
                 if version_comment:
-                    typer.echo(
+                    echo(
                         "Page was created, but Confluence API does not support setting the version comment for"
-                        " page creation. The comment was not provided."
+                        " page creation. The comment was not saved in the page history."
                     )
+                page.page_id = page_created.page_id
             else:
-                typer.echo(f"Not creating page '{page.page_title}'")
-                report.unprocessed_pages += [
-                    (page, "User cancelled creation when prompted")
-                ]
-
-        if page_id and upload_files:
-            typer.echo("Uploading the files")
-            if page == target_page:
-                for path in files:
-                    if path.is_file():
-                        typer.echo(f"\tUploading file {path.name}")
-                        state.confluence_instance.attach_file(
-                            str(path), name=path.name, page_id=page_id
-                        )
-                        typer.echo(f"\tSubmitted file {path.name}")
-                typer.echo("Done uploading files")
-
-    typer.echo("Finished processing pages")
+                always_echo(f"Not creating page '{page.page_title}'")
+                report.unprocessed_pages += [(page, page_created.comment)]
+
+        if upload_files and target_page.page_id is not None:
+            attach_files_to_page(page=target_page, files=files, state=state)
+
+    always_echo("Finished processing pages")
 
     if state.print_report:
-        typer.echo(report)
+        always_echo(report)
 
 
 @app.command()
@@ -289,33 +288,41 @@ def validate(
 ):
     """Validates the provided settings. If 'online' is true - tries to fetch the space from the config using the
     supplied credentials."""
+    echo = state.print_function
+    echo_err = state.print_stderr
+
     if online:
-        typer.echo("Validating settings against the Confluence instance from config")
+        echo("Validating settings against the Confluence instance from config")
         try:
             space_key = state.config.pages[0].page_space
-            typer.echo(f"Trying to get {space_key}...")
+            state.print_function(f"Trying to get {space_key}...")
             space_id = state.confluence_instance.get_space(space_key).get("id")
         except ConnectionError:
-            typer.echo(
-                f"Could not connect to {state.config.auth.url}. Make sure it is correct"
+            echo_err(
+                f"Could not connect to {state.config.auth.url}. Make sure it is correct",
             )
             raise typer.Abort(1)
         except ApiError as e:
-            typer.echo(f"Got an API error, details: {e.reason}")
+            echo_err(f"Got an API error, details: {e.reason}")
             raise typer.Abort(1)
         else:
-            typer.echo(f"Got space id #{space_id}.")
-    typer.echo("Validation successful")
-    return
+            echo(f"Got space id #{space_id}.")
+    echo("Validation successful")
 
 
 @app.command()
 def create_config(
     local_only: Optional[bool] = typer.Option(
-        False, "--local-only", help="Create config only in the local folder"
+        False,
+        "--local-only",
+        show_default=False,
+        help="Create config only in the local folder.",
     ),
     home_only: Optional[bool] = typer.Option(
-        False, "--home-only", help="Create config only in the $XDG_CONFIG_HOME"
+        False,
+        "--home-only",
+        show_default=False,
+        help="Create config only in the $XDG_CONFIG_HOME.",
     ),
 ):
     """Runs configuration wizard. The wizard guides through setting up values for config."""
@@ -328,6 +335,10 @@ def create_config(
     )
     from functools import partial
 
+    echo = state.print_function
+    confirm = state.confirm_function
+    prompt = state.prompt_function
+
     home_config_location = xdg.xdg_config_home() / "confluence_poster/config.toml"
 
     all_params = (
@@ -362,14 +373,7 @@ def create_config(
             "if a page does not redefine it",
             required=False,
         ),
-        DialogParameter("pages.page1.page_title", comment="The title of the page"),
-        DialogParameter("pages.page1.page_file", comment="File containing page text"),
-        DialogParameter(
-            "pages.page1.page_space",
-            comment="Key of the space with the page",
-            required=False,
-        ),
-    )
+    ) + generate_page_dialog_params(1)
     home_only_params = (
         "author",
         "auth.confluence_url",
@@ -385,16 +389,16 @@ def create_config(
     page_add_dialog = partial(page_add_dialog, config_print_function=_print_config_file)
 
     # Initial prompt
-    typer.echo("Starting config wizard.")
-    typer.echo("This wizard will guide you through creating the configuration files.")
+    echo("Starting config wizard.")
+    echo("This wizard will guide you through creating the configuration files.")
     answer = ""
     if not any([local_only, home_only]):
-        typer.echo(
+        echo(
             "Since neither '--local-only' nor '--home-only' were specified, wizard will guide you through creating "
             f"config files in {home_config_location.parent}(XDG_CONFIG_HOME) and {Path.cwd()}(local directory)"
         )
 
-        answer = typer.prompt(
+        answer = prompt(
             f"Create config in {home_config_location.parent}? [Y/n/q]"
             "\n* 'n' skips to config in the local directory"
             "\n* 'q' will exit the wizard\n",
@@ -418,21 +422,21 @@ def create_config(
 
     if home_only:
         # If --home-only is specified - no need to create another one in local folder
-        typer.echo("--home-only specified, not attempting to create any more configs.")
+        echo("--home-only specified, not attempting to create any more configs.")
         raise typer.Exit()
 
     if not local_only:
         # If local-only is passed - no need to ask for confirmation of creating a local only config
-        local_answer = typer.confirm(
+        local_answer = confirm(
             f"Proceed to create config in {Path.cwd()}?", default=True
         )
         if not local_answer:
-            typer.echo("Exiting.")
+            echo("Exiting.")
             raise typer.Exit()
 
     # Create config in current working directory
-    typer.echo("Creating config in local directory.")
-    local_config_name = typer.prompt(
+    echo("Creating config in local directory.")
+    local_config_name = prompt(
         "Please provide the name of local config", type=str, default=default_config_name
     )
 
@@ -447,10 +451,10 @@ def create_config(
             # None means the user does not want to overwrite the file
             break
 
-    while typer.confirm("Add more pages?", default=False):
+    while confirm("Add more pages?", default=False):
         page_add_dialog(Path.cwd() / local_config_name)
 
-    typer.echo(
+    echo(
         "Configuration wizard finished. Consider running the `validate` command to check the generated config"
     )
 
@@ -459,7 +463,7 @@ def create_config(
 def main(
     ctx: typer.Context,
     version: Optional[bool] = typer.Option(
-        None, "--version", help="Show version and exit", callback=version_callback
+        None, "--version", help="Show version and exit.", callback=version_callback
     ),
     config: Path = typer.Option(
         default=default_config_name,
@@ -476,6 +480,10 @@ def main(
         help="Provide a parent title to search for."
         " Applicable if there is only one page.",
     ),
+    page_file: Optional[Path] = typer.Option(
+        None,
+        help="Provide the path to the file containing page text. Allows passing '-' to read from stdin.",
+    ),
     password: Optional[str] = typer.Option(
         None, help="Supply the password in command line.", envvar="CONFLUENCE_PASSWORD"
     ),
@@ -484,7 +492,7 @@ def main(
         "--force",
         show_default=False,
         help="Force overwrite the pages. Skips all checks for different author of the updated page. "
-        "To set for individual pages you can specify field 'force_overwrite' in config",
+        "To set for individual pages you can specify field 'force_overwrite' in config.",
     ),
     force_create: Optional[bool] = typer.Option(
         False,
@@ -511,17 +519,47 @@ def main(
         show_default=False,
         help="Enable debug logging. " "Not enabled by default.",
     ),
+    quiet: Optional[bool] = typer.Option(
+        False,
+        "--quiet",
+        show_default=False,
+        help="Suppresses certain output.",
+    ),
 ):
-    """Supplementary script for writing confluence wiki articles in
-    vim. Uses information from the config to post the article content to confluence.
+    """Supplementary script for writing Confluence articles in
+    local editor. Uses information from the config to post the article content to confluence.
+
     """
-    typer.echo("Starting up confluence_poster")
+
+    if ctx.invoked_subcommand == "convert-markdown":
+        quiet = True
+
+    if str(page_file) == "-":
+        state.filter_mode = True
+        if ctx.invoked_subcommand not in {"convert-markdown", "post-page"}:
+            typer.echo(
+                f"Invoked command, {ctx.invoked_subcommand} is not compatible with reading page text from stdin",
+                err=True,
+            )
+            raise typer.Exit(3)
+    else:
+        state.filter_mode = False
+
+    state.quiet = quiet
+
+    echo = state.print_function
+    always_echo = state.always_print_function
+    echo_err = state.print_stderr
+
+    echo("Starting confluence_poster")
     if debug:
         from pprint import pprint
 
-        typer.echo("Set options:")
+        always_echo("Running in debug mode.")
+
+        echo("Set options:")
         state.debug = True
-        typer.echo(pprint(locals()))
+        echo(pprint(locals()))
         # Set global debug to true, for other modules
         basicConfig(level=DEBUG, format="%(asctime)s %(levelname)s %(message)s")
     else:
@@ -535,26 +573,34 @@ def main(
         state.print_report = report
         state.minor_edit = minor_edit
 
-        typer.echo("Reading config")
+        echo("Reading config")
         try:
             confluence_config = load_config(config)
         except FileNotFoundError as e:
-            typer.echo("Config file not found. Consider running `create-config`")
+            echo_err("Config file not found. Consider running `create-config`")
             raise e
         state.config = confluence_config
 
         # Check that the parameters are not used with more than 1 page in the config
-        if page_title or parent_page_title:
+        if page_title or parent_page_title or page_file:
             if len(confluence_config.pages) > 1:
-                typer.echo(
-                    "Page title specified as a parameter but there are more than 1 page in the config. "
-                    "Aborting."
+                echo_err(
+                    "Page title, parent page title or page file specified as a parameter "
+                    "but there are more than 1 page in the config.\n"
+                    "These parameters are intended to be used with only one page.\n"
+                    "Please specify them in the config.\n"
+                    "Aborting.",
                 )
                 raise typer.Exit(1)
             if page_title:
                 state.config.pages[0].page_title = page_title
             if parent_page_title:
                 state.config.pages[0].parent_page_title = parent_page_title
+            if page_file:
+                if state.filter_mode:
+                    state.config.pages[0].page_text = sys.stdin.read()
+                else:
+                    state.config.pages[0].page_file = page_file
 
         # Validate password
         try:
@@ -562,8 +608,8 @@ def main(
                 _ for _ in [password, confluence_config.auth.password] if _ is not None
             )
         except StopIteration:
-            typer.echo(
-                "Password is not specified in environment, parameter or the config. Aborting"
+            echo_err(
+                "Password is not specified in environment, parameter or the config. Aborting",
             )
             raise typer.Exit(1)
 
diff --git a/confluence_poster/main_helpers.py b/confluence_poster/main_helpers.py
index f799486..a97f176 100644
--- a/confluence_poster/main_helpers.py
+++ b/confluence_poster/main_helpers.py
@@ -1,11 +1,26 @@
 from atlassian import Confluence
-from dataclasses import dataclass
-from confluence_poster.poster_config import Page
-from typing import Union
+from dataclasses import dataclass, field
+from typing import Union, Callable, List
+from typer import echo, prompt, confirm
+from functools import partial
+
+from confluence_poster.poster_config import Page, Config
 
 """File that contains procedures used inside main.py's functions"""
 
 
+def get_page_url(
+    page_title: str, space: str, confluence: Confluence
+) -> Union[str, None]:
+    """Retrieves page URL"""
+    if page := confluence.get_page_by_title(space=space, title=page_title, expand=""):
+        # according to Atlassian REST API reference, '_links' is a legitimate way to access links
+        page_link = confluence.url + page["_links"]["webui"]
+        return page_link
+    else:
+        return None
+
+
 def check_last_updated_by(
     page_id: int, username_to_check: str, confluence_instance: Confluence
 ) -> (bool, str):
@@ -30,3 +45,72 @@ class PostedPage(Page):
     """Merges independently set fields with the runtime-set fields"""
 
     version_comment: Union[str, None] = None
+    page_id: Union[int, None] = None
+
+
+@dataclass
+class StateConfig:
+    """Holds the shared state between typer commands"""
+
+    force: bool = False
+    debug: bool = False
+    confluence_instance: Union[None, Confluence] = None
+    config: Union[None, Config] = None
+    minor_edit: bool = False
+    print_report: bool = False
+    force_create: bool = False
+    created_pages: List[int] = field(default_factory=list)
+    _filter_mode: bool = False
+    quiet: bool = False
+
+    @property
+    def print_function(self) -> Callable:
+        if self.quiet:
+            # noinspection PyUnusedLocal
+            def _quiet_func(*args, **kwargs):
+                pass
+
+            return _quiet_func
+        else:
+            return echo
+
+    @property
+    def always_print_function(self) -> Callable:
+        return echo
+
+    @property
+    def prompt_function(self) -> Callable:
+        if self.filter_mode:
+            # noinspection PyUnusedLocal
+            def _raise_exception(*args, **kwargs):
+                raise Exception("Prompt invoked in filter mode!")
+
+            return _raise_exception
+        else:
+            return prompt
+
+    @property
+    def confirm_function(self) -> Callable:
+        if self.filter_mode:
+            # noinspection PyUnusedLocal
+            def _raise_exception(*args, **kwargs):
+                # TODO: show prompt?
+                raise Exception("Confirmation prompt invoked in filter mode!")
+
+            return _raise_exception
+        else:
+            return confirm
+
+    @property
+    def print_stderr(self) -> Callable:
+        return partial(echo, err=True)
+
+    @property
+    def filter_mode(self):
+        return self._filter_mode
+
+    @filter_mode.setter
+    def filter_mode(self, value: bool):
+        self._filter_mode = value
+        if value:
+            self.quiet = True
diff --git a/confluence_poster/page_creation_helpers.py b/confluence_poster/page_creation_helpers.py
new file mode 100644
index 0000000..7259ccf
--- /dev/null
+++ b/confluence_poster/page_creation_helpers.py
@@ -0,0 +1,60 @@
+from typing import Union
+
+from confluence_poster.main_helpers import StateConfig
+from confluence_poster.convert_utils import get_representation_for_format
+from confluence_poster.page_location_helpers import determine_location
+from confluence_poster.poster_config import Page
+
+
+class CreationResult:
+    def __init__(
+        self,
+        page_created: bool,
+        page_id: Union[int, None] = None,
+        comment: Union[str, None] = None,
+    ):
+        self.page_created = page_created
+        self.page_id = page_id
+        self.comment = comment
+
+    def __bool__(self):
+        return self.page_created
+
+
+def create_page(page: Page, state: StateConfig, create_in_root: bool) -> CreationResult:
+    """Handles user input for page creation.
+
+    :return CreationResult that contains info on whether the page was created and its ID
+    """
+    echo = state.print_function
+    confirm = state.confirm_function
+
+    if state.force_create or confirm("Should the page be created?", default=True):
+        if location := determine_location(
+            page=page, create_in_root=create_in_root, state=state
+        ):
+            echo("Creating page...")
+            page_id = state.confluence_instance.create_page(
+                space=page.page_space,
+                title=page.page_title,
+                body=page.page_text,
+                parent_id=location.parent_page_id,
+                representation=get_representation_for_format(
+                    page.page_file_format
+                ).value,
+            )["id"]
+            if location.parent_page_id is None:
+                page_location_msg = f"in root of the space '{page.page_space}'"
+            else:
+                page_location_msg = f"under page #{location.parent_page_id}, '{location.parent_page_name}'"
+
+            echo(
+                f"Created page #{page_id} {page_location_msg} called '{page.page_title}'."
+            )
+            return CreationResult(True, page_id)
+        else:
+            return CreationResult(
+                False, comment="Could not determine location for the page."
+            )
+    else:
+        return CreationResult(False, comment="User cancelled creation when prompted.")
diff --git a/confluence_poster/page_location_helpers.py b/confluence_poster/page_location_helpers.py
new file mode 100644
index 0000000..76c6723
--- /dev/null
+++ b/confluence_poster/page_location_helpers.py
@@ -0,0 +1,104 @@
+from typing import Union
+
+from confluence_poster.main_helpers import StateConfig, get_page_url
+from confluence_poster.poster_config import Page
+
+
+def _find_parent(parent_name: str, space: str, state: StateConfig) -> Union[int, None]:
+    """Helper function to locate the parent page.
+
+    :return page id if parent is found, None otherwise
+    """
+    state.print_function(f"Looking for the parent page with title '{parent_name}'")
+    if parent_page := state.confluence_instance.get_page_by_title(
+        space=space, title=parent_name, expand=""
+    ):
+        # according to Atlassian REST API reference, '_links' is a legitimate way to access links
+        parent_link = get_page_url(parent_name, space, state.confluence_instance)
+        _parent_id = parent_page["id"]
+        state.print_function(
+            f"Found page #{_parent_id}, called '{parent_name}'. URL is: {parent_link}"
+        )
+        return _parent_id
+    else:
+        state.print_function(f"Parent page '{parent_name}' not found")
+        return None
+
+
+def _prompt_for_parent(state: StateConfig) -> str:
+    """Function that handles user input """
+    prompt = state.prompt_function
+    return prompt("Which page should the script look for?")
+
+
+class LocationResult:
+    def __init__(
+        self,
+        create_page: bool,
+        parent_page_id: Union[int, None] = None,
+        parent_page_name: Union[str, None] = None,
+    ):
+        self.create_page = create_page
+        self.parent_page_id = parent_page_id
+        self.parent_page_name = parent_page_name
+
+    def __bool__(self):
+        return self.create_page
+
+
+def determine_location(
+    page: Page,
+    create_in_root: bool,
+    state: StateConfig,
+) -> LocationResult:
+    """Handles user input when creating the page
+
+    :return bool - whether page should be created, page_id of parent page if it exists, None for creating in root
+    """
+    echo = state.print_function
+    confirm = state.confirm_function
+    always_echo = state.always_print_function
+
+    if create_in_root:
+        echo(f"Will create the page in root of space {page.page_space}")
+        return LocationResult(True, None)
+
+    if page.parent_page_title:
+        echo(
+            f"Will create the page under the specified parent page '{page.parent_page_title}'"
+        )
+        parent_page_id = _find_parent(
+            parent_name=page.parent_page_title, space=page.page_space, state=state
+        )
+        if parent_page_id is not None:
+            return LocationResult(True, parent_page_id, page.parent_page_title)
+        else:
+            always_echo(
+                f"Provided page '{page.parent_page_title}' not found in space '{page.page_space}'.\n"
+                "Skipping page."
+            )
+            return LocationResult(False)
+
+    else:
+        while confirm(
+            f"Should the script look for a parent in space {page.page_space}?"
+            f" (N to be prompted to create the page in the space root)\n"
+            f"Hint: you can pass --create-in-root or --parent-page-title to skip this prompt."
+        ):
+            parent_title = _prompt_for_parent(state)
+            if parent_id := _find_parent(
+                parent_name=parent_title, space=page.page_space, state=state
+            ):
+                if confirm(
+                    f"Proceed to create the page '{page.page_title}' under page '{parent_title}'?"
+                ):
+                    return LocationResult(True, parent_id, parent_title)
+                else:
+                    return LocationResult(False)
+        else:
+            if confirm(
+                f"Create the page in the root of space '{page.page_space}'? (N will skip the page)"
+            ):
+                return LocationResult(True, None)
+            else:
+                return LocationResult(False)
diff --git a/confluence_poster/poster_config.py b/confluence_poster/poster_config.py
index 21c4bdf..f7d2ea3 100644
--- a/confluence_poster/poster_config.py
+++ b/confluence_poster/poster_config.py
@@ -5,6 +5,15 @@
 from operator import attrgetter
 from itertools import groupby
 from collections import UserDict
+from marshmallow import Schema, fields, ValidationError
+from enum import Enum
+
+
+class AllowedFileFormat(str, Enum):
+    confluencewiki = "confluencewiki"
+    markdown = "markdown"
+    html = "html"
+    none = "None"
 
 
 @dataclass
@@ -12,8 +21,8 @@ class Page:
     page_title: str
     page_file: str
     page_space: Union[str, None]
-    parent_page_title: Union[str, None]
-    force_overwrite: Union[bool, None] = False
+    parent_page_title: Union[str, None] = None
+    _page_text: str = ""
 
     def __eq__(self, other) -> bool:
         if not isinstance(other, Page):
@@ -22,6 +31,39 @@ def __eq__(self, other) -> bool:
             self.page_space == other.page_space
         )
 
+    @property
+    def page_text(self) -> str:
+        if self._page_text == "":
+            if (_file := Path(self.page_file)).exists():
+                self._page_text = Path(self.page_file).read_text()
+        return self._page_text
+
+    @page_text.setter
+    def page_text(self, value: str):
+        self._page_text = value
+
+    page_file_format: AllowedFileFormat = AllowedFileFormat.none
+    force_overwrite: Union[bool, None] = False
+
+
+class AllowedFileFormatField(fields.Field):
+    def _deserialize(self, value, attr, data, **kwargs):
+        if not isinstance(value, AllowedFileFormat):
+            raise ValidationError(f"Invalid value for field: {value}")
+        return value.value
+
+
+class PageSchema(Schema):
+    page_title = fields.Str()
+    page_file = fields.Str()
+    page_space = fields.Str()
+    parent_page_title = fields.Str(missing=None)
+    _page_text = fields.Str()
+    page_file_format = AllowedFileFormatField(
+        default=AllowedFileFormat.none, missing=AllowedFileFormat.none
+    )
+    force_overwrite = fields.Boolean(default=False)
+
 
 @dataclass
 class Auth:
@@ -94,11 +136,14 @@ def pages(self, pages: dict):
                                 )
 
                     page = Page(
-                        item_content.get("page_title", None),
-                        item_content["page_file"],
-                        item_content.get("page_space", None),
-                        item_content.get("page_parent_title", None),
-                        item_content.get("force_overwrite", False),
+                        page_title=item_content.get("page_title", None),
+                        page_file=item_content["page_file"],
+                        page_file_format=AllowedFileFormat(
+                            item_content.get("page_file_format", "None")
+                        ),
+                        page_space=item_content.get("page_space", None),
+                        parent_page_title=item_content.get("page_parent_title", None),
+                        force_overwrite=item_content.get("force_overwrite", False),
                     )
                     self.__pages.append(page)
             else:
diff --git a/docs/templates/skeleton.jinja2 b/docs/templates/skeleton.jinja2
index 9a740fc..9c73984 100644
--- a/docs/templates/skeleton.jinja2
+++ b/docs/templates/skeleton.jinja2
@@ -1,6 +1,61 @@
 {% set section = typer_help_chapters['Description'] %}
 {{ section['intro'] }}
 
+May be used either on its own:
+
+    $ confluence_poster post-page
+
+As a filter:
+
+    $ cat file.md | confluence_poster --file-format markdown post-page
+
+# Getting started
+
+## Installation
+
+1. Install the project from PyPI:
+
+    ```console
+    $ pip install confluence-poster
+    ```
+
+2. Create the config manually ([sample available in repo](https://github.com/VTimofeenko/confluence_poster/blob/master/config.toml)) or run `confluence_poster create-config` to run a wizard
+
+## Sample usage
+
+Given the following files in the current directory:
+
+```
+├── attachment1.docx
+├── attachment2.png
+├── poster_config.toml
+└── page1.md
+```
+
+`poster_config.toml` has:
+
+```toml
+[pages]
+[pages.page1]
+page_title = "Some page"
+page_file = "page1.md"
+page_space = "SPACE"
+```
+
+config inside `${HOME}/.config/confluence_poster/` has the authentication information and the Confluence URL.
+
+Running
+
+```console
+$ confluence_poster --config poster_config.toml post-page --upload-files attachment1.docx attachment2.png
+```
+
+will attempt to locate the page on Confluence instance, update its content to the text in `page1.md` and attach the files to it.
+
+If the script cannot locate it, it will prompt the user to create it, optionally under a parent page.
+
+# Details
+
 {{ section['usage'] }}
 
 {{ section['options']|replace("Options", "General Options", 1) }}
@@ -29,21 +84,11 @@ These options can be specified for any `COMMAND` except for  `create-config` whi
 
 {{ section['options'] }}
 
-# Installation
-
-Install the project from PyPI:
-
-```console
-$ pip install confluence-poster
-```
-
-To start using `confluence_poster` either create the config manually or run `confluence_poster create-config` to start
-configuration wizard which will guide you through the configuration.
 
 # Config format
 
 By default the {{ tool_name }} tries to look for config file `config.toml` in the directory where it is invoked and in
-XDG_CONFIG_HOME. The config format is as follows:
+$XDG_CONFIG_HOME. The config format is as follows:
 
 ```toml
 {{ config_toml }}```
@@ -51,9 +96,18 @@ XDG_CONFIG_HOME. The config format is as follows:
 **Note on password and Cloud instances**: if confluence is hosted by Atlassian, the password is the API token.
 Follow instructions at [this link](https://confluence.atlassian.com/cloud/api-tokens-938839638.html).
 
+# Text formats
+
+{{ tool_name }} supports the following formats for posting pages:
+* [Confluencewiki](https://confluence.atlassian.com/doc/confluence-wiki-markup-251003035.html)
+* Markdown
+* Html
+
+The format may be specified explicitly in the config file, passed during the runtime, or the script will try to guess it by the file extension.
+
 # Contrib directory
 
-There are shell completions for bash and zsh as well as a sample of
+There are shell completions for bash and zsh(generated through [typer](typer.tiangolo.com/)) as well as a sample of
 [git post-commit hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks).
 
 # See also
diff --git a/setup.py b/setup.py
index b7bdcb4..9e4c599 100644
--- a/setup.py
+++ b/setup.py
@@ -27,10 +27,19 @@
         "requests",
         "xdg>=5.0.1",
         "tomlkit==0.7.0",
-        "click>=7.0.0",
+        "click>=7.1.1, <7.2.0",
+        "markdown==3.3.3",
+        "marshmallow==3.10.0",
     ],
     python_requires=">=3.8, <4",
-    tests_require=["pytest", "pytest-cov", "faker"],
+    tests_require=[
+        "pytest",
+        "pytest-cov",
+        "faker",
+        "beautifulsoup4==4.9.3",
+        "bs4",
+        "lxml",
+    ],
     extras_require={"docs": ["jinja2", "typer-cli"]},
     classifiers=[
         "Development Status :: 4 - Beta",


Test patch:
diff --git a/.run/tests/mass_tests/Offline tests.run.xml b/.run/tests/mass_tests/Offline tests.run.xml
index ad67e39..8a53f10 100644
--- a/.run/tests/mass_tests/Offline tests.run.xml
+++ b/.run/tests/mass_tests/Offline tests.run.xml
@@ -1,11 +1,11 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Offline tests" type="tests" factoryName="py.test" folderName="Mass tests">
+  <configuration default="false" name="Offline tests" type="tests" factoryName="py.test">
     <module name="confluence_poster" />
     <option name="INTERPRETER_OPTIONS" value="" />
     <option name="PARENT_ENVS" value="true" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
+    <option name="SDK_HOME" value="" />
     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
+    <option name="IS_MODULE_SDK" value="true" />
     <option name="ADD_CONTENT_ROOTS" value="true" />
     <option name="ADD_SOURCE_ROOTS" value="true" />
     <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
diff --git a/.run/tests/mass_tests/Online tests.run.xml b/.run/tests/mass_tests/Online tests.run.xml
index 16e112f..01b6ba3 100644
--- a/.run/tests/mass_tests/Online tests.run.xml
+++ b/.run/tests/mass_tests/Online tests.run.xml
@@ -1,11 +1,11 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Online tests" type="tests" factoryName="py.test" folderName="Mass tests">
+  <configuration default="false" name="Online tests" type="tests" factoryName="py.test">
     <module name="confluence_poster" />
     <option name="INTERPRETER_OPTIONS" value="" />
     <option name="PARENT_ENVS" value="true" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
+    <option name="SDK_HOME" value="" />
     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
+    <option name="IS_MODULE_SDK" value="true" />
     <option name="ADD_CONTENT_ROOTS" value="true" />
     <option name="ADD_SOURCE_ROOTS" value="true" />
     <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
diff --git a/.run/tests/mass_tests/Run all pytests.run.xml b/.run/tests/mass_tests/Run all pytests.run.xml
index 8102971..fe11210 100644
--- a/.run/tests/mass_tests/Run all pytests.run.xml
+++ b/.run/tests/mass_tests/Run all pytests.run.xml
@@ -1,11 +1,11 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Run all pytests" type="tests" factoryName="py.test" folderName="Mass tests">
+  <configuration default="false" name="Run all pytests" type="tests" factoryName="py.test">
     <module name="confluence_poster" />
     <option name="INTERPRETER_OPTIONS" value="" />
     <option name="PARENT_ENVS" value="true" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
+    <option name="SDK_HOME" value="" />
     <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
+    <option name="IS_MODULE_SDK" value="true" />
     <option name="ADD_CONTENT_ROOTS" value="true" />
     <option name="ADD_SOURCE_ROOTS" value="true" />
     <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
diff --git a/.run/tests/specific_tests/Config unittest.run.xml b/.run/tests/specific_tests/Config unittest.run.xml
deleted file mode 100644
index c97bf87..0000000
--- a/.run/tests/specific_tests/Config unittest.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Config unittest" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/unit tests/test_config.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Config wizard command test.run.xml b/.run/tests/specific_tests/Config wizard command test.run.xml
deleted file mode 100644
index 576942b..0000000
--- a/.run/tests/specific_tests/Config wizard command test.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Config wizard command test" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/command tests/test_config_wizard_command.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Config wizard dialog test.run.xml b/.run/tests/specific_tests/Config wizard dialog test.run.xml
deleted file mode 100644
index a470f4e..0000000
--- a/.run/tests/specific_tests/Config wizard dialog test.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Config wizard dialog test" type="tests" factoryName="py.test" folderName="unit tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/unit tests/test_config_wizard_dialog.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Config wizard helpers unittest.run.xml b/.run/tests/specific_tests/Config wizard helpers unittest.run.xml
deleted file mode 100644
index b050b04..0000000
--- a/.run/tests/specific_tests/Config wizard helpers unittest.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Config wizard helpers unittest" type="tests" factoryName="py.test" folderName="unit tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/unit tests/test_config_wizard_helpers.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Loading config unittest.run.xml b/.run/tests/specific_tests/Loading config unittest.run.xml
deleted file mode 100644
index 11277d6..0000000
--- a/.run/tests/specific_tests/Loading config unittest.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Loading config unittest" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_config_loader.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Main unittest.run.xml b/.run/tests/specific_tests/Main unittest.run.xml
deleted file mode 100644
index 6d4cc14..0000000
--- a/.run/tests/specific_tests/Main unittest.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Main unittest" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_main_params.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/One page.run.xml b/.run/tests/specific_tests/One page.run.xml
deleted file mode 100644
index be65b4c..0000000
--- a/.run/tests/specific_tests/One page.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="One page" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_post_one_page.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Two pages.run.xml b/.run/tests/specific_tests/Two pages.run.xml
deleted file mode 100644
index b4d0588..0000000
--- a/.run/tests/specific_tests/Two pages.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Two pages" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_two_pages.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Upload_files test.run.xml b/.run/tests/specific_tests/Upload_files test.run.xml
deleted file mode 100644
index 0349842..0000000
--- a/.run/tests/specific_tests/Upload_files test.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Upload_files test" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_upload_files.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/.run/tests/specific_tests/Validate unittest.run.xml b/.run/tests/specific_tests/Validate unittest.run.xml
deleted file mode 100644
index 378741e..0000000
--- a/.run/tests/specific_tests/Validate unittest.run.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="Validate unittest" type="tests" factoryName="py.test" folderName="Specific tests">
-    <module name="confluence_poster" />
-    <option name="INTERPRETER_OPTIONS" value="" />
-    <option name="PARENT_ENVS" value="false" />
-    <option name="SDK_HOME" value="$PROJECT_DIR$/venv38/bin/python" />
-    <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
-    <option name="IS_MODULE_SDK" value="false" />
-    <option name="ADD_CONTENT_ROOTS" value="true" />
-    <option name="ADD_SOURCE_ROOTS" value="true" />
-    <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
-    <option name="_new_keywords" value="&quot;&quot;" />
-    <option name="_new_parameters" value="&quot;&quot;" />
-    <option name="_new_additionalArguments" value="&quot;&quot;" />
-    <option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_validate.py&quot;" />
-    <option name="_new_targetType" value="&quot;PATH&quot;" />
-    <method v="2" />
-  </configuration>
-</component>
\ No newline at end of file
diff --git a/tests/command tests/flag tests/test_create_in_space_root_flag.py b/tests/command tests/flag tests/test_create_in_space_root_flag.py
new file mode 100644
index 0000000..e80ba19
--- /dev/null
+++ b/tests/command tests/flag tests/test_create_in_space_root_flag.py
@@ -0,0 +1,43 @@
+import pytest
+from typer.testing import CliRunner, Result
+from confluence_poster.main import app
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    join_input,
+    generate_local_config,
+    get_pages_ids_from_stdout,
+    confluence_instance,
+)
+from functools import partial
+
+
+"""Tests the --create-in-space-root flag of post-page"""
+pytestmark = pytest.mark.online
+
+runner = CliRunner(mix_stderr=False)
+default_run_cmd = generate_run_cmd(
+    runner=runner, app=app, default_args=["post-page", "--create-in-space-root"]
+)
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+@pytest.mark.parametrize(
+    "page_count",
+    list(range(1, 3)),
+    ids=lambda c: f"Create {c} pages in the root with the --create-in-space-root flag",
+)
+def test_create_one_page_in_space_root(tmp_path, page_count):
+    config_file, config = generate_local_config(tmp_path, pages=page_count)
+    result: Result = run_with_config(
+        config_file=config_file, input=join_input(user_input=("Y",) * page_count)
+    )
+    created_pages = get_pages_ids_from_stdout(result.stdout)
+    assert result.exit_code == 0
+    assert (
+        result.stderr is ""
+    )  # if parent is not found - get_parent_content_id logs to stderr
+    assert len(created_pages) == page_count
+    for page_id in created_pages:
+        parent = confluence_instance.get_parent_content_id(page_id)
+        assert parent is None, "Page should had been created in space root"
diff --git a/tests/command tests/flag tests/test_debug.py b/tests/command tests/flag tests/test_debug.py
new file mode 100644
index 0000000..d396783
--- /dev/null
+++ b/tests/command tests/flag tests/test_debug.py
@@ -0,0 +1,20 @@
+import pytest
+from typer.testing import CliRunner, Result
+from confluence_poster.main import app
+from utils import generate_run_cmd, run_with_config
+from functools import partial
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner()
+default_run_cmd = generate_run_cmd(
+    runner=runner, app=app, default_args=["--debug", "validate", "--online"]
+)
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+def test_debug_flag_warns_and_prints_options(make_one_page_config):
+    config_file, config = make_one_page_config
+    result: Result = run_with_config(config_file=config_file)
+    assert result.exit_code == 0
+    assert "Set options" in result.stdout
diff --git a/tests/command tests/flag tests/test_flag_page_file.py b/tests/command tests/flag tests/test_flag_page_file.py
new file mode 100644
index 0000000..8eca905
--- /dev/null
+++ b/tests/command tests/flag tests/test_flag_page_file.py
@@ -0,0 +1,137 @@
+from functools import partial
+
+from bs4 import BeautifulSoup
+
+import pytest
+from typer.testing import CliRunner, Result
+
+from confluence_poster.main import app
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    generate_fake_page,
+    create_single_page_input,
+    get_page_id_from_stdout,
+    get_page_body,
+)
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner(mix_stderr=False)
+default_run_cmd = generate_run_cmd(runner=runner, app=app, default_args=["--page-file"])
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+def test_post_page_with_real_file(make_one_page_config, tmp_path):
+    config_file, config = make_one_page_config
+    _, content, page_file = generate_fake_page(tmp_path)
+
+    result: Result = run_with_config(
+        config_file=config_file,
+        other_args=[page_file, "post-page"],
+        input=create_single_page_input,
+    )
+    assert result.exit_code == 0
+    assert get_page_body(get_page_id_from_stdout(result.stdout)) == f"<p>{content}</p>"
+
+
+@pytest.mark.parametrize(
+    "file_format",
+    ("html", "confluencewiki", "markdown"),
+    ids=lambda _format: "`confluence_poster --page-file - --force-create post-page "
+    f"--create-in-space-root --file-format {_format}`",
+)
+def test_post_new_page_stdin(make_one_page_config, tmp_path, file_format):
+    """Tests posting three sources with the effectively same text"""
+    config_file, config = make_one_page_config
+
+    content = {
+        "html": """<h1>Title</h1>
+<ul>
+<li>one</li>
+<li>two</li>
+</ul>""",
+        "confluencewiki": """h1. Title
+* one
+* two""",
+        "markdown": """# Title
+* one
+* two""",
+    }
+
+    result: Result = run_with_config(
+        config_file=config_file,
+        other_args=[
+            "-",
+            "--force-create",
+            "post-page",
+            "--create-in-space-root",
+            "--file-format",
+            file_format,
+        ],
+        input=content[file_format],
+    )
+    assert result.exit_code == 0
+    assert BeautifulSoup(
+        get_page_body(get_page_id_from_stdout(result.stdout)),
+        features="lxml",
+    ) == BeautifulSoup(
+        content["html"],
+        features="lxml",
+    )
+
+
+@pytest.mark.parametrize(
+    "text_source",
+    ("-", "file"),
+    ids=lambda source: f"`confluence_poster --page-file {source} convert-markdown",
+)
+def test_convert_markdown(make_one_page_config, tmp_path, text_source):
+    config_file, config = make_one_page_config
+    md_text = "# Header\nTest\n\n* One\n* Two"
+    if text_source == "file":
+        md_file = tmp_path / "file.md"
+        md_file.write_text(md_text)
+        _source = md_file
+        _input = ""
+    else:
+        _source = "-"
+        _input = md_text
+
+    result: Result = run_with_config(
+        config_file=config_file,
+        other_args=[
+            _source,
+            "--force-create",
+            "convert-markdown",
+        ],
+        input=_input,
+    )
+    assert result.exit_code == 0
+    assert (
+        result.stdout
+        == """<h1>Header</h1>
+<p>Test</p>
+<ul>
+<li>One</li>
+<li>Two</li>
+</ul>
+"""
+    )
+
+
+@pytest.mark.parametrize(
+    "command",
+    ("validate", "create-config"),
+    ids=lambda cmd: f"`confluence_poster --page-file - ${cmd}`, callback should exit with code 3",
+)
+def test_other_commands(make_one_page_config, tmp_path, command):
+    config_file, config = make_one_page_config
+    _, content, page_file = generate_fake_page(tmp_path)
+    result: Result = run_with_config(
+        config_file=config_file,
+        other_args=["-", "--force-create", f"{command}"],
+        input=content,
+    )
+    assert result.exit_code == 3
+    assert "not compatible" in result.stderr
diff --git a/tests/command tests/flag tests/test_flag_quiet.py b/tests/command tests/flag tests/test_flag_quiet.py
new file mode 100644
index 0000000..705b96f
--- /dev/null
+++ b/tests/command tests/flag tests/test_flag_quiet.py
@@ -0,0 +1,21 @@
+import pytest
+from typer.testing import CliRunner, Result
+from confluence_poster.main import app
+from utils import generate_run_cmd, run_with_config
+from functools import partial
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner()
+default_run_cmd = generate_run_cmd(
+    runner=runner, app=app, default_args=["--quiet", "validate", "--online"]
+)
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+def test_validate_quiet(make_one_page_config):
+    """Tests quiet flag execution: checks that the stdout is empty and the exit code is 0"""
+    config_file, config = make_one_page_config
+    result: Result = run_with_config(config_file=config_file)
+    assert result.exit_code == 0
+    assert result.stdout is ""
diff --git a/tests/command tests/test_force_create.py b/tests/command tests/flag tests/test_force_create.py
similarity index 100%
rename from tests/command tests/test_force_create.py
rename to tests/command tests/flag tests/test_force_create.py
diff --git a/tests/command tests/test_force_flag.py b/tests/command tests/flag tests/test_force_flag.py
similarity index 100%
rename from tests/command tests/test_force_flag.py
rename to tests/command tests/flag tests/test_force_flag.py
diff --git a/tests/command tests/test_force_overwrite_page_flag.py b/tests/command tests/flag tests/test_force_overwrite_page_flag.py
similarity index 100%
rename from tests/command tests/test_force_overwrite_page_flag.py
rename to tests/command tests/flag tests/test_force_overwrite_page_flag.py
diff --git a/tests/test_main_params.py b/tests/command tests/flag tests/test_main_params.py
similarity index 69%
rename from tests/test_main_params.py
rename to tests/command tests/flag tests/test_main_params.py
index 9474ba5..aca7ec0 100644
--- a/tests/test_main_params.py
+++ b/tests/command tests/flag tests/test_main_params.py
@@ -1,15 +1,25 @@
 from typer.testing import CliRunner
-from confluence_poster.main import app
-from confluence_poster.main import main, state
-from utils import mk_tmp_file
+
+# noinspection PyUnresolvedReferences
+from confluence_poster.main import app, main, state
+from confluence_poster.main_helpers import StateConfig
+from utils import mk_tmp_file, generate_fake_page
 import pytest
-from dataclasses import asdict
 
 
 runner = CliRunner()
 pytestmark = pytest.mark.offline
 
 
+@pytest.fixture(scope="module")
+def teardown_state():
+    """Fixture to clear the state after this module is done. Necessary to prevent state being polluted by pytest."""
+    yield
+    # noinspection PyGlobalUndefined
+    global state
+    state = StateConfig()
+
+
 def test_app_no_params_ok():
     """Tests that passing no parameters works. Should output the help section"""
     result = runner.invoke(app)
@@ -36,7 +46,6 @@ def test_different_config(tmp_path, config_file):
     result = runner.invoke(app, ["--config", str(_config_file), "validate"])
     if config_file is not None:
         assert result.exit_code == 0
-        assert state.config.pages[0].page_title == new_page_name
     else:
         assert result.exit_code == 1
         assert (
@@ -65,22 +74,31 @@ def test_page_title_specified_two_pages(tmp_path, param):
         ],
     )
     assert _.exit_code == 1
-    assert "Page title specified as a parameter" in _.stdout
+    assert "Please specify them in the config." in _.stdout
 
 
 @pytest.mark.parametrize("password_source", [None, "cmdline", "environment", "config"])
 def test_no_passwords_anywhere(tmp_path, password_source, monkeypatch):
     """Checks that the password is applied, depending on the source.
     If source is 'None' - no password is supplied anywhere and validation fails."""
+    config_file = mk_tmp_file(tmp_path, key_to_pop="auth.password")
     if password_source is None:
-        config_file = mk_tmp_file(tmp_path, key_to_pop="auth.password")
         result = runner.invoke(app, ["--config", str(config_file), "validate"])
     else:
         if password_source == "cmdline":
-            result = runner.invoke(app, ["--password", password_source, "validate"])
+            result = runner.invoke(
+                app,
+                [
+                    "--config",
+                    str(config_file),
+                    "--password",
+                    password_source,
+                    "validate",
+                ],
+            )
         elif password_source == "environment":
             monkeypatch.setenv("CONFLUENCE_PASSWORD", password_source)
-            result = runner.invoke(app, ["validate"])
+            result = runner.invoke(app, ["--config", str(config_file), "validate"])
         else:
             config_file = mk_tmp_file(
                 tmp_path, key_to_update="auth.password", value_to_update=password_source
@@ -108,27 +126,33 @@ def test_one_page_no_title_in_config(tmp_path):
     assert state.config.pages[0].page_title == page_title
 
 
-@pytest.mark.parametrize("param", ["debug", "force", "minor_edit", "force_create"])
-def test_binary_parameter_in_state(param):
-    """Checks that the binary parameters are reflected in state."""
-    result = runner.invoke(app, [f'--{param.replace("_", "-")}', "validate"])
+@pytest.mark.parametrize(
+    "is_cloud",
+    (True, False),
+    ids=lambda is_cloud: f"Create config with is_cloud set to {is_cloud}, runs `confluence_poster validate`",
+)
+def test_cloud_api(tmp_path, is_cloud):
+    """Checks that the logic to handle what version of Atlassian API is to be used works well"""
+    config = mk_tmp_file(
+        tmp_path, key_to_update="auth.is_cloud", value_to_update=is_cloud
+    )
+    result = runner.invoke(app, ["--config", str(config), "validate"])
     assert result.exit_code == 0
-    assert asdict(state).get(param)
+    if is_cloud:
+        assert state.confluence_instance.api_version == "cloud"
+    else:
+        assert state.confluence_instance.api_version == "latest"
 
 
-def test_cloud_api(tmp_path):
-    """Checks that the logic to handle what version of Atlassian API is to be used works well"""
-    result = runner.invoke(app, ["--force", "validate"])
-    assert result.exit_code == 0
-    assert state.confluence_instance.api_version == "latest"
-    not_cloud_config = mk_tmp_file(
-        tmp_path, key_to_update="auth.is_cloud", value_to_update=True
-    )
+def test_page_file_not_specified_filter_mode(tmp_path, make_one_page_config):
+    """Runs the command with page_file set to a real file. Checks that filter_mode is off."""
+    config_file, config = make_one_page_config
+    _, content, page_file = generate_fake_page(tmp_path)
     result = runner.invoke(
-        app, ["--config", str(not_cloud_config), "--force", "validate"]
+        app, ["--page-file", page_file, "--config", config_file, "validate"]
     )
     assert result.exit_code == 0
-    assert state.confluence_instance.api_version == "cloud"
+    assert state.filter_mode is False
 
 
 def test_show_version():
diff --git a/tests/command tests/flag tests/test_post_page_file_format_flag.py b/tests/command tests/flag tests/test_post_page_file_format_flag.py
new file mode 100644
index 0000000..93888a3
--- /dev/null
+++ b/tests/command tests/flag tests/test_post_page_file_format_flag.py
@@ -0,0 +1,60 @@
+from typer.testing import CliRunner, Result
+import pytest
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    generate_local_config,
+    create_single_page_input,
+    mk_tmp_file,
+)
+from functools import partial
+from confluence_poster.main import app
+
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner()
+default_run_cmd = generate_run_cmd(runner=runner, app=app, default_args=["post-page"])
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+@pytest.mark.parametrize(
+    "file_format",
+    ["confluencewiki", "markdown", "html", "None"],
+    ids=lambda f: f"Run `confluence_poster post-page --file-format {f}`",
+)
+def test_all_file_formats(make_one_page_config, file_format):
+    config_file, config = make_one_page_config
+
+    result: Result = run_with_config(
+        config_file=config_file,
+        input=create_single_page_input,
+        other_args=["--file-format", file_format],
+    )
+    assert result.exit_code == 0
+
+
+def test_could_not_guess_file_format(make_one_page_config, tmp_path):
+    config_file, config = make_one_page_config
+    config_file = mk_tmp_file(
+        tmp_path=tmp_path,
+        config_to_clone=config_file,
+        key_to_update="pages.page1.page_file",
+        value_to_update="some_file_no_ext",
+    )
+    result: Result = run_with_config(
+        config_file=config_file, input=create_single_page_input
+    )
+    assert result.exit_code == 1
+    assert "Consider specifying it manually" in result.stdout
+
+
+def test_error_if_multiple_pages(tmp_path):
+    config_file, config = generate_local_config(tmp_path, pages=2)
+    result: Result = run_with_config(
+        config_file=config_file,
+        input=create_single_page_input,
+        other_args=["--file-format", "html"],
+    )
+    assert "Consider adding it to the config file." in result.stdout
+    assert result.exit_code == 1
diff --git a/tests/command tests/flag tests/test_report.py b/tests/command tests/flag tests/test_report.py
new file mode 100644
index 0000000..594afb0
--- /dev/null
+++ b/tests/command tests/flag tests/test_report.py
@@ -0,0 +1,50 @@
+import pytest
+from typer.testing import CliRunner
+from confluence_poster.main import app
+from confluence_poster.main import get_page_url
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    join_input,
+    page_created,
+    get_pages_ids_from_stdout,
+    confluence_instance,
+)
+from functools import partial
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner()
+default_run_cmd = generate_run_cmd(
+    runner=runner, app=app, default_args=["--report", "post-page"]
+)
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+# TODO: more tests
+# TODO: test --quiet interaction
+
+
+def test_post_page_report(make_one_page_config):
+    config_file, config = make_one_page_config
+    result = run_with_config(input=join_input("N"), config_file=config_file)
+
+    assert result.exit_code == 0
+    assert (
+        "Not creating page" in result.stdout
+    ), "Script did not report that page is not created"
+    assert not page_created(
+        page_title=config.pages[0].page_title
+    ), "Page was not supposed to be created"
+    assert (
+        len(get_pages_ids_from_stdout(result.stdout)) == 0
+    ), "Detected a page that was created!"
+    page = config.pages[0]
+    assert get_page_url(page.page_title, page.page_space, confluence_instance) is None
+    assert (
+        "Created pages:\nNone\nUpdated pages:\nNone\nUnprocessed pages:"
+        in result.stdout
+    )
+    assert (
+        f"{page.page_space}::{page.page_title} Reason: User cancelled creation when prompted"
+        in result.stdout
+    )
diff --git a/tests/test_upload_files.py b/tests/command tests/flag tests/test_upload_files.py
similarity index 96%
rename from tests/test_upload_files.py
rename to tests/command tests/flag tests/test_upload_files.py
index b5b9df3..6858ea8 100644
--- a/tests/test_upload_files.py
+++ b/tests/command tests/flag tests/test_upload_files.py
@@ -67,7 +67,7 @@ def test_upload_files_single_page_config(setup_page, gen_attachments, file_count
     assert result.exit_code == 0
     assert "Uploading the files" in result.stdout
     assert f"\tUploading file {filename}" in result.stdout
-    assert f"\tSubmitted file {filename}" in result.stdout
+    assert f"\tUploaded file {filename}" in result.stdout
     assert "Done uploading files" in result.stdout
 
 
@@ -84,7 +84,7 @@ def test_upload_files_single_page_does_not_exist(tmp_path, gen_attachments):
 
 def test_upload_files_single_page_title_supplied(setup_page, gen_attachments):
     """Runs
-    post --config <config> --page-title <some page name> upload-files file1 file2...
+    confluence_poster --config <config> --page-title <some page name> post-page --upload-files file1 file2...
     And makes sure the files were attached to the proper page
     """
     page_id, config_file = setup_page
@@ -147,7 +147,7 @@ def test_upload_files_multiple_pages_no_agree(setup_two_pages):
         input="N\n",
         other_args=["some_attachment.txt"],
     )
-    assert result.exit_code == 1
+    assert result.exit_code == 3
     assert "Aborting" in result.stdout
 
 
@@ -183,5 +183,5 @@ def test_upload_files_multiple_pages_default(setup_two_pages):
     result: Result = upload_files(
         config_file=two_page_config_file, input="\n", other_args=["some_attachment.txt"]
     )
-    assert result.exit_code == 1
+    assert result.exit_code == 3
     assert "Aborting" in result.stdout
diff --git a/tests/command tests/test_version_comment.py b/tests/command tests/flag tests/test_version_comment.py
similarity index 91%
rename from tests/command tests/test_version_comment.py
rename to tests/command tests/flag tests/test_version_comment.py
index 138dd7d..dca0599 100644
--- a/tests/command tests/test_version_comment.py
+++ b/tests/command tests/flag tests/test_version_comment.py
@@ -1,7 +1,13 @@
 import pytest
 from typer.testing import CliRunner, Result
-from confluence_poster.main import app, state
-from utils import generate_run_cmd, run_with_config, generate_local_config, mk_fake_file
+from confluence_poster.main import app
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    generate_local_config,
+    mk_fake_file,
+    confluence_instance,
+)
 from atlassian.confluence import Confluence
 from functools import partial
 
@@ -47,7 +53,7 @@ def run_with_comment(comment: str):
             _get_version_comment(
                 page_title=config.pages[0].page_title,
                 space="LOC",
-                confluence=state.confluence_instance,
+                confluence=confluence_instance,
             )
             == version_comment
         )
@@ -101,7 +107,7 @@ def test_multiple_pages_with_comment(apply_config_answer, tmp_path):
                 _get_version_comment(
                     page_title=page.page_title,
                     space=page.page_space,
-                    confluence=state.confluence_instance,
+                    confluence=confluence_instance,
                 )
                 == "update_comment"
             )
@@ -115,7 +121,7 @@ def test_multiple_pages_with_comment(apply_config_answer, tmp_path):
                 _get_version_comment(
                     page_title=page.page_title,
                     space=page.page_space,
-                    confluence=state.confluence_instance,
+                    confluence=confluence_instance,
                 )
                 == _checked_comment
             )
@@ -125,7 +131,7 @@ def test_multiple_pages_with_comment(apply_config_answer, tmp_path):
                 _get_version_comment(
                     page_title=page.page_title,
                     space=page.page_space,
-                    confluence=state.confluence_instance,
+                    confluence=confluence_instance,
                 )
                 == ""
             )
diff --git a/tests/command tests/test_config_wizard_command.py b/tests/command tests/test_config_wizard_command.py
index 108ff96..09ea6be 100644
--- a/tests/command tests/test_config_wizard_command.py
+++ b/tests/command tests/test_config_wizard_command.py
@@ -97,11 +97,13 @@ def test_no_params_values_filled():
         "LOC",  # default space
         "Some page title",
         "page1.confluencewiki",
+        "confluencewiki",
         "LOC",
         "Y",  # save the edit
         "Y",  # add more pages
         "Other page title",
         "page2.confluencewiki",
+        "",
         "LOC",
         "Y",
     )
diff --git a/tests/command tests/test_convert_command.py b/tests/command tests/test_convert_command.py
new file mode 100644
index 0000000..ec38484
--- /dev/null
+++ b/tests/command tests/test_convert_command.py
@@ -0,0 +1,57 @@
+import pytest
+from typer.testing import CliRunner
+from confluence_poster.main import app
+from utils import generate_run_cmd, run_with_config
+from functools import partial
+from pathlib import Path
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner(mix_stderr=False)
+default_run_cmd = generate_run_cmd(
+    runner=runner, app=app, default_args=["convert-markdown"]
+)
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+def test_convert_command_default_behavior(make_one_page_config):
+    """Makes sure that there is no extra output in stdout when running the tool"""
+    config_file, config = make_one_page_config
+    Path(config.pages[0].page_file).write_text("# Title\n* one\n* two")
+
+    result = run_with_config(config_file=config_file, other_args=[])
+
+    assert result.exit_code == 0
+    assert (
+        result.stdout
+        == """<h1>Title</h1>
+<ul>
+<li>one</li>
+<li>two</li>
+</ul>
+"""
+    )
+
+
+def test_convert_online(make_one_page_config):
+    config_file, config = make_one_page_config
+    Path(config.pages[0].page_file).write_text("# Title\n* one\n* two")
+
+    result = run_with_config(
+        config_file=config_file, other_args=["--use-confluence-converter"]
+    )
+    assert result.stdout == """<h1>Title</h1> <ul> <li>one <li>two </ul> \n"""
+    assert (
+        "Using the converter built into Confluence which is labeled as private API."
+        in result.stderr
+    ), "This command uses Confluence private API, user should be warned"
+    assert (
+        "file-format html" in result.stderr
+    ), "Recommendation about file format should be given"
+
+
+def test_convert_multiple_pages(make_two_page_config):
+    config_file, config = make_two_page_config
+    result = run_with_config(config_file=config_file)
+
+    assert result.exit_code == 1
diff --git a/tests/test_post_one_page.py b/tests/command tests/test_post_one_page.py
similarity index 89%
rename from tests/test_post_one_page.py
rename to tests/command tests/test_post_one_page.py
index c962d46..5b69260 100644
--- a/tests/test_post_one_page.py
+++ b/tests/command tests/test_post_one_page.py
@@ -1,6 +1,6 @@
 from typer.testing import CliRunner
 import pytest
-from confluence_poster.main import app, get_page_url
+from confluence_poster.main import app
 from utils import (
     clone_local_config,
     generate_run_cmd,
@@ -15,7 +15,6 @@
     get_pages_ids_from_stdout,
     get_page_id_from_stdout,
     run_with_config,
-    generate_local_config,
     join_input,
     create_single_page_input,
 )
@@ -53,11 +52,6 @@ def run_with_title(
     )
 
 
-@pytest.fixture(scope="function")
-def make_one_page_config(tmp_path):
-    return generate_local_config(tmp_path, pages=1)
-
-
 def test_page_overridden_title(make_one_page_config):
     """Tests that the title supplied through command line is applied"""
     config_file, config = make_one_page_config
@@ -86,26 +80,21 @@ def test_post_single_page_no_parent(make_one_page_config):
     )
     assert result.exit_code == 0
     assert "Looking for page" in result.stdout
-    assert "Should it be created?" in result.stdout  # checking the prompt
+    assert "Should the page be created?" in result.stdout  # checking the prompt
     assert (
         "Should the script look for a parent in space" in result.stdout
     )  # checking the prompt
     assert "Create the page in the root" in result.stdout  # checking the prompt
-    assert f"Page '{config.pages[0].page_title}' not found" in result.stdout
+    assert f"Could not find page '{config.pages[0].page_title}'" in result.stdout
     assert "Creating page" in result.stdout
+    assert "Created page" in result.stdout
     assert "Finished processing pages" in result.stdout
 
 
-@pytest.mark.parametrize(
-    "report",
-    (False, True),
-    ids=lambda report: f"User refuses to create pages. Run with{'out'*report} report",
-)
-def test_not_create_if_refused(make_one_page_config, report):
+def test_not_create_if_refused(make_one_page_config):
     config_file, config = make_one_page_config
     result = run_with_config(
-        input="N\n",
-        pre_args=["--report"] * report,
+        input=join_input("N"),
         config_file=config_file,
     )
     assert result.exit_code == 0
@@ -119,20 +108,6 @@ def test_not_create_if_refused(make_one_page_config, report):
         len(get_pages_ids_from_stdout(result.stdout)) == 0
     ), "Detected a page that was created!"
 
-    if report:
-        page = config.pages[0]
-        assert (
-            get_page_url(page.page_title, page.page_space, confluence_instance) is None
-        )
-        assert (
-            "Created pages:\nNone\nUpdated pages:\nNone\nUnprocessed pages:"
-            in result.stdout
-        )
-        assert (
-            f"{page.page_space}::{page.page_title} Reason: User cancelled creation when prompted"
-            in result.stdout
-        )
-
 
 @pytest.mark.parametrize(
     "parent_page_title_source",
@@ -155,7 +130,7 @@ def test_post_single_page_with_parent(setup_page, parent_page_title_source, tmp_
         )
         assert "Which page should the script look for?" in result.stdout
         assert "URL is:" in result.stdout
-        assert "Proceed to create?" in result.stdout
+        assert "Proceed to create the page" in result.stdout
     else:
 
         if parent_page_title_source == "cmdline":
@@ -212,7 +187,7 @@ def test_skip_in_space_root():
         config_file=real_confluence_config,
     )
     assert "Looking for page" in result.stdout
-    assert "Should it be created?" in result.stdout  # checking the prompt
+    assert "Should the page be created?" in result.stdout  # checking the prompt
     assert (
         "Should the script look for a parent in space" in result.stdout
     )  # checking the prompt
@@ -315,5 +290,5 @@ def test_refuse_to_create_with_parent(setup_page):
     )
     assert "Which page should the script look for?" in result.stdout
     assert "URL is:" in result.stdout
-    assert "Proceed to create?" in result.stdout
+    assert "Should the page be created?" in result.stdout  # checking the prompt
     assert not page_created(page_title)
diff --git a/tests/test_two_pages.py b/tests/command tests/test_two_pages.py
similarity index 100%
rename from tests/test_two_pages.py
rename to tests/command tests/test_two_pages.py
diff --git a/tests/test_validate.py b/tests/command tests/test_validate.py
similarity index 52%
rename from tests/test_validate.py
rename to tests/command tests/test_validate.py
index 6a12626..a1b59b4 100644
--- a/tests/test_validate.py
+++ b/tests/command tests/test_validate.py
@@ -1,7 +1,8 @@
 from typer.testing import CliRunner, Result
 from confluence_poster.main import app
-from utils import clone_local_config, generate_run_cmd
+from utils import clone_local_config, generate_run_cmd, run_with_config
 import pytest
+from functools import partial
 
 """This module requires an instance of confluence running. 
 
@@ -11,16 +12,16 @@
 
 pytestmark = pytest.mark.online
 
-runner = CliRunner()
+runner = CliRunner(mix_stderr=False)
 mk_local_config = clone_local_config()
-run_with_config = generate_run_cmd(
-    runner=runner, app=app, default_args=["validate", "--online"]
-)
+default_run_cmd = generate_run_cmd(runner=runner, app=app, default_args=["validate"])
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
 
 
-def test_all_ok():
+def test_all_ok(make_one_page_config):
     """Validates that the validate --online does not break during execution"""
-    result: Result = run_with_config()
+    config_file, config = make_one_page_config
+    result: Result = run_with_config(config_file=config_file, other_args=["--online"])
     assert "Validating settings" in result.stdout
     assert "Trying to get" in result.stdout
     assert "Validation successful" in result.stdout
@@ -34,9 +35,9 @@ def test_could_not_connect(tmp_path):
         key_to_update="auth.confluence_url",
         value_to_update="http://localhost:64000",
     )
-    result: Result = run_with_config(config=config)
+    result: Result = run_with_config(config_file=config, other_args=["--online"])
     assert result.exit_code == 1
-    assert "Could not connect" in result.stdout
+    assert "Could not connect" in result.stderr
 
 
 def test_nonexistent_space(tmp_path):
@@ -46,6 +47,20 @@ def test_nonexistent_space(tmp_path):
         key_to_update="pages.page1.page_space",
         value_to_update="nonexistent_space_key",
     )
-    result: Result = run_with_config(config=config)
+    result: Result = run_with_config(config_file=config, other_args=["--online"])
     assert result.exit_code == 1
-    assert "API error" in result.stdout
+    assert "API error" in result.stderr
+
+
+@pytest.mark.parametrize(
+    "online",
+    (True, False),
+    ids=lambda online: f"`confluence_poster --quiet validate{' --online' * online}` produces no output",
+)
+def test_validate_with_quiet(online, make_one_page_config):
+    config_file, config = make_one_page_config
+    result: Result = run_with_config(
+        config_file=config_file, pre_args=["--quiet"], other_args=["--online"] * online
+    )
+    assert result.exit_code == 0
+    assert result.stdout == ""
diff --git a/tests/config.toml b/tests/config.toml
new file mode 120000
index 0000000..e1fd6d8
--- /dev/null
+++ b/tests/config.toml
@@ -0,0 +1,1 @@
+../config.toml
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index a0e315c..8a2399d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,6 +10,7 @@
 from typer.testing import CliRunner
 from atlassian import errors
 from confluence_poster.main import app
+from confluence_poster.poster_config import Config
 from typing import Tuple, List
 
 
@@ -56,3 +57,13 @@ def _create_pages(page_count: int = 1) -> Tuple[str, List[Tuple[int, str]]]:
         return config_file, page_list
 
     return _create_pages
+
+
+@pytest.fixture(scope="function")
+def make_one_page_config(tmp_path) -> (str, Config):
+    return generate_local_config(tmp_path, pages=1)
+
+
+@pytest.fixture(scope="function")
+def make_two_page_config(tmp_path) -> (str, Config):
+    return generate_local_config(tmp_path, pages=2)
diff --git a/tests/test_config_loader.py b/tests/unit tests/test_config_loader.py
similarity index 100%
rename from tests/test_config_loader.py
rename to tests/unit tests/test_config_loader.py
diff --git a/tests/unit tests/test_config_wizard_dialog.py b/tests/unit tests/test_config_wizard_dialog.py
index 38781f2..48edee9 100644
--- a/tests/unit tests/test_config_wizard_dialog.py
+++ b/tests/unit tests/test_config_wizard_dialog.py
@@ -1,11 +1,10 @@
-import io
 from confluence_poster.config_wizard import config_dialog, DialogParameter
 from pathlib import Path
 from typer.testing import CliRunner
 from tomlkit import parse
 import pytest
-from typing import Iterable
 from itertools import product
+from utils import setup_input
 
 # noinspection PyProtectedMember
 from confluence_poster.config_wizard import (
@@ -19,11 +18,6 @@
 runner = CliRunner()
 
 
-def setup_input(monkeypatch, cli_input: Iterable[str]):
-    """Macro to monkeypatch th"""
-    monkeypatch.setattr("sys.stdin", io.StringIO("\n".join(cli_input) + "\n"))
-
-
 @pytest.mark.parametrize(
     "save_agree",
     [True, False, None],
@@ -227,7 +221,7 @@ def test_incremental_config_dialog(prepare_config_file, monkeypatch, capsys):
 
 def test_page_add_dialog(prepare_config_file, monkeypatch):
     config, config_text = prepare_config_file
-    setup_input(monkeypatch, ["title", "testfile", "SPC", "Y"])
+    setup_input(monkeypatch, ["title", "testfile", "", "SPC", "Y"])
     assert page_add_dialog(config)
     assert (
         get_attribute_by_path("pages.page1.page_title", parse(config.read_text()))
diff --git a/tests/unit tests/test_convert_functions.py b/tests/unit tests/test_convert_functions.py
new file mode 100644
index 0000000..073c43a
--- /dev/null
+++ b/tests/unit tests/test_convert_functions.py
@@ -0,0 +1,35 @@
+import pytest
+from confluence_poster.convert_utils import (
+    post_to_convert_api,
+    convert_using_markdown_lib,
+)
+from utils import confluence_instance
+
+pytestmark = pytest.mark.online
+
+md_text = "# Title\n* One\n* Two"
+
+
+@pytest.mark.parametrize(
+    "text,exception",
+    ((md_text, False),),
+    ids=[
+        "Post good markdown - get result",
+    ],
+)
+def test_post_to_convert_api(text, exception):
+    assert (
+        post_to_convert_api(confluence=confluence_instance, text=text)
+        == "<h1>Title</h1> <ul> <li>One <li>Two </ul> "
+    )
+
+
+def test_convert_using_markdown_lib():
+    assert (
+        convert_using_markdown_lib(md_text)
+        == """<h1>Title</h1>
+<ul>
+<li>One</li>
+<li>Two</li>
+</ul>"""
+    )
diff --git a/tests/unit tests/test_could_not_guess_file_format_at_runtime.py b/tests/unit tests/test_could_not_guess_file_format_at_runtime.py
new file mode 100644
index 0000000..d8a6112
--- /dev/null
+++ b/tests/unit tests/test_could_not_guess_file_format_at_runtime.py
@@ -0,0 +1,35 @@
+from typer.testing import CliRunner, Result
+import pytest
+from utils import (
+    generate_run_cmd,
+    run_with_config,
+    create_single_page_input,
+    mk_tmp_file,
+)
+from functools import partial
+from confluence_poster.main import app
+
+
+pytestmark = pytest.mark.online
+
+runner = CliRunner()
+default_run_cmd = generate_run_cmd(runner=runner, app=app, default_args=["post-page"])
+run_with_config = partial(run_with_config, default_run_cmd=default_run_cmd)
+
+
+def test_warn_user_could_not_guess_file(tmp_path, make_one_page_config):
+    config_file, config = make_one_page_config
+
+    config_file = mk_tmp_file(
+        tmp_path=tmp_path,
+        config_to_clone=config_file,
+        key_to_update="pages.page1.page_file",
+        value_to_update="filename_no_extension",
+    )
+
+    result: Result = run_with_config(
+        config_file=config_file,
+        input=create_single_page_input,
+    )
+    assert "--help" in result.stdout
+    assert result.exit_code == 1
diff --git a/tests/unit tests/test_get_representation_for_format.py b/tests/unit tests/test_get_representation_for_format.py
new file mode 100644
index 0000000..91152eb
--- /dev/null
+++ b/tests/unit tests/test_get_representation_for_format.py
@@ -0,0 +1,34 @@
+import pytest
+from confluence_poster.convert_utils import (
+    Representation,
+    get_representation_for_format,
+)
+from confluence_poster.poster_config import AllowedFileFormat
+
+pytestmark = pytest.mark.offline
+
+arg_values = (
+    (AllowedFileFormat.markdown, Representation.editor, False),
+    (AllowedFileFormat.confluencewiki, Representation.wiki, False),
+    (AllowedFileFormat.html, Representation.editor, False),
+    (AllowedFileFormat.none, "", True),
+    ("SomeString", "", True),
+)
+
+
+ids = map(
+    lambda _: f"Representation: {_[0]} -> {_[1]}{' with exception' * _[2]}", arg_values
+)
+
+
+@pytest.mark.parametrize(
+    argnames=("file_format", "representation", "exception"),
+    argvalues=arg_values,
+    ids=ids,
+)
+def test_get_representation_for_format(file_format, representation, exception):
+    if exception:
+        with pytest.raises(ValueError):
+            get_representation_for_format(file_format)
+    else:
+        assert get_representation_for_format(file_format) == representation
diff --git a/tests/unit tests/test_guess_page_file_format.py b/tests/unit tests/test_guess_page_file_format.py
new file mode 100644
index 0000000..2b2311f
--- /dev/null
+++ b/tests/unit tests/test_guess_page_file_format.py
@@ -0,0 +1,26 @@
+import pytest
+from confluence_poster.convert_utils import guess_file_format
+from confluence_poster.poster_config import AllowedFileFormat
+
+pytestmark = pytest.mark.offline
+
+arg_values = (
+    ("file.md", AllowedFileFormat.markdown, False),
+    ("file.confluencewiki", AllowedFileFormat.confluencewiki, False),
+    ("file.html", AllowedFileFormat.html, False),
+    ("file.docx", AllowedFileFormat.none, True),
+)
+ids = map(lambda _: f"Filename: {_[0]} -> {_[1]}{' with exception' * _[2]}", arg_values)
+
+
+@pytest.mark.parametrize(
+    argnames=("filename", "file_format", "exception"),
+    argvalues=arg_values,
+    ids=ids,
+)
+def test_file_format(filename, file_format, exception):
+    if exception:
+        with pytest.raises(ValueError):
+            guess_file_format(filename)
+    else:
+        assert guess_file_format(filename) == file_format
diff --git a/tests/unit tests/test_main_helpers_state.py b/tests/unit tests/test_main_helpers_state.py
new file mode 100644
index 0000000..7a3128d
--- /dev/null
+++ b/tests/unit tests/test_main_helpers_state.py
@@ -0,0 +1,65 @@
+import pytest
+from utils import setup_input
+
+from confluence_poster.main_helpers import StateConfig
+
+pytestmark = pytest.mark.offline
+
+
+@pytest.mark.parametrize(
+    "quiet",
+    (None, True, False),
+    ids=(
+        ".quiet is default -> print prints, print_stderr prints",
+        ".quiet is true -> print does not print, print_stderr prints",
+        ".quiet is false -> print prints, print_stderr prints",
+    ),
+)
+def test_print_function_print_stderr(capsys, quiet):
+    """Checks that the print function is suppressed <=> quiet is set to True"""
+    s = StateConfig()
+    if quiet is not None:
+        s.quiet = quiet
+    else:
+        assert not s.quiet
+    s.print_function("Output")
+    s.print_stderr("Stderr")
+    captured = capsys.readouterr()
+    assert (captured.out == "Output\n") == (not s.quiet)
+    assert captured.err == "Stderr\n"
+
+
+@pytest.mark.parametrize(
+    "filter_mode",
+    (None, True, False),
+    ids=(
+        "filter_mode is default -> prompt and confirm work",
+        "filter_mode is true -> Exception",
+        "filter_mode is false -> prompt and confirm work",
+    ),
+)
+def test_prompt_function_confirm_function(monkeypatch, filter_mode):
+    s = StateConfig()
+    if filter_mode is not None:
+        s.filter_mode = filter_mode
+    else:
+        assert not s.filter_mode
+    setup_input(monkeypatch, cli_input=("Y", "Y"))
+
+    if s.filter_mode:
+        with pytest.raises(Exception):
+            s.prompt_function("Prompt?")
+        with pytest.raises(Exception):
+            s.confirm_function("Prompt?")
+    else:
+        _ = s.prompt_function("Prompt?")
+        assert _ == "Y"
+        _ = s.confirm_function("Prompt?")
+        assert _
+
+
+def test_filter_mode():
+    """Checks that filter also toggles quiet mode"""
+    s = StateConfig()
+    s.filter_mode = True
+    assert s.quiet
diff --git a/tests/unit tests/test_page_class.py b/tests/unit tests/test_page_class.py
new file mode 100644
index 0000000..751d297
--- /dev/null
+++ b/tests/unit tests/test_page_class.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+from marshmallow import ValidationError
+from dataclasses import asdict
+import pytest
+
+from confluence_poster.poster_config import (
+    Page,
+    PageSchema,
+    AllowedFileFormat,
+)
+
+pytestmark = pytest.mark.offline
+
+
+@pytest.mark.parametrize(
+    "file_format",
+    ["confluencewiki", "markdown", "html", "None"],
+    ids=lambda f_format: f"'{f_format}' is specified for the page",
+)
+def test_file_format_supported(file_format):
+    file_format = AllowedFileFormat(file_format)
+    p = Page(
+        page_title="Title",
+        page_file="/tmp/file.confluencewiki",
+        page_space="LOC",
+        page_file_format=file_format,
+    )
+    assert p.page_file_format == file_format
+    PageSchema().load(asdict(p))
+
+
+def test_file_format_default():
+    p = Page(
+        page_title="Title",
+        page_file="/tmp/file.confluencewiki",
+        page_space="LOC",
+    )
+    assert p.page_file_format.value is "None"
+
+
+def test_file_format_not_str():
+    with pytest.raises(ValidationError):
+        # noinspection PyTypeChecker
+        _ = Page(
+            page_title="Title",
+            page_file="/tmp/file.confluencewiki",
+            page_space="LOC",
+            page_file_format=1,
+        )
+        PageSchema().load(asdict(_))
+
+
+def test_file_format_different_str():
+    with pytest.raises(ValidationError):
+        # noinspection PyTypeChecker
+        _ = Page(
+            page_title="Title",
+            page_file="/tmp/file.confluencewiki",
+            page_space="LOC",
+            page_file_format="not supported",
+        )
+        PageSchema().load(asdict(_))
+
+
+@pytest.mark.parametrize(
+    "file_exists", [True, False], ids=["Page file exists", "Page file does not exist"]
+)
+def test_page_text(tmp_path, file_exists):
+    content = "h1. Test\nColorless green ideas"
+    updated_content = content + " sleep furiously"
+
+    if file_exists:
+        page_file: Path = tmp_path / "page.confluencewiki"
+        page_file.write_text(content)
+    else:
+        page_file = tmp_path / "none.md"
+
+    p = Page(page_title="title", page_file=str(page_file), page_space="LOC")
+
+    if file_exists:
+        assert p.page_text == content
+
+    p.page_text = updated_content
+    assert p.page_text == updated_content
diff --git a/tests/utils.py b/tests/utils.py
index 1038c42..f20f0fc 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -3,17 +3,28 @@
 from functools import partial
 from typing import Callable, Union, List, Iterable, Tuple
 from faker import Faker
-from confluence_poster.poster_config import Config, Page
 from atlassian import Confluence
 from pathlib import Path
 import re
 from inspect import currentframe
+import io
 
+from confluence_poster.poster_config import Config
 
-real_confluence_config = (
-    "local_config.toml"  # The config filename for testing against local instance
-)
-other_user_config = "local_config_other_user.toml"  # Config with a different user
+
+def locate_real_confluence_config_file(config_name="local_config.toml"):
+    if Path(config_name).exists():
+        return config_name
+    else:
+        tests_directory = tuple(
+            filter(lambda p: str(p).endswith("/tests"), Path.cwd().parents)
+        )[0]
+        return str(tests_directory / config_name)
+
+
+# The config filename for testing against local instance
+real_confluence_config = locate_real_confluence_config_file()
+other_user_config = locate_real_confluence_config_file("local_config_other_user.toml")
 
 if Path(real_confluence_config).exists():
     real_config = Config(real_confluence_config)
@@ -32,7 +43,7 @@
 def mk_tmp_file(
     tmp_path,
     filename: str = None,
-    config_to_clone: str = "config.toml",
+    config_to_clone: str = locate_real_confluence_config_file("config.toml"),
     key_to_pop: str = None,  # key path in form 'first_key.second_key' to descend into config
     key_to_update: str = None,
     value_to_update=None,
@@ -165,8 +176,11 @@ def generate_local_config(
             tmp_path,
             filename=str(new_config),
             config_to_clone=new_config,
-            key_to_update=f"pages.page{page_number+1}",
-            value_to_update={"page_title": title, "page_file": filename},
+            key_to_update=f"pages.page{page_number + 1}",
+            value_to_update={
+                "page_title": title,
+                "page_file": f"{filename}",
+            },
         )
     return new_config, Config(str(new_config))
 
@@ -245,25 +259,6 @@ def run_with_config(
             type(_record_pages) is set
         ), "Looks like record_set manipulation is going to fail"
         _record_pages |= _created_pages
-    config = Config(config_file)
-    for page_id in _created_pages:
-        """Make sure that the pages got created with proper content"""
-        if "pre_args" in kwargs and "--page-title" in kwargs.get("pre_args"):
-            # Page title specified manually, only one page in config
-            found_page = config.pages[0]
-            _ = kwargs.get("pre_args").index("--page-title")
-            page_title = kwargs.get("pre_args")[_ + 1]
-        else:
-            page_title = get_page_title(page_id)
-            found_page: Page = next(
-                _ for _ in config.pages if _.page_title == page_title
-            )
-
-        with open(found_page.page_file, "r") as page_file:
-            page_text = page_file.read()
-            assert page_text in get_page_body(
-                page_id
-            ), f"Page {page_title} has incorrect content"
 
     return result
 
@@ -295,6 +290,11 @@ def replace_new_author(config_file, tmp_path):
     )
 
 
+def setup_input(monkeypatch, cli_input: Iterable[str]):
+    """Macro to monkeypatch the input"""
+    monkeypatch.setattr("sys.stdin", io.StringIO("\n".join(cli_input) + "\n"))
+
+
 create_single_page_input = join_input(
     "Y", "N", "Y"
 )  # sequence if inputs to create one page