ssb-pubmd 0.0.13__py3-none-any.whl → 0.0.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ssb_pubmd/__init__.py +2 -2
- ssb_pubmd/__main__.py +62 -47
- ssb_pubmd/browser_request_handler.py +85 -0
- ssb_pubmd/markdown_syncer.py +9 -9
- {ssb_pubmd-0.0.13.dist-info → ssb_pubmd-0.0.14.dist-info}/METADATA +2 -6
- ssb_pubmd-0.0.14.dist-info/RECORD +10 -0
- ssb_pubmd/browser_context.py +0 -71
- ssb_pubmd-0.0.13.dist-info/RECORD +0 -10
- {ssb_pubmd-0.0.13.dist-info → ssb_pubmd-0.0.14.dist-info}/LICENSE +0 -0
- {ssb_pubmd-0.0.13.dist-info → ssb_pubmd-0.0.14.dist-info}/WHEEL +0 -0
- {ssb_pubmd-0.0.13.dist-info → ssb_pubmd-0.0.14.dist-info}/entry_points.txt +0 -0
ssb_pubmd/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""SSB Pubmd."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .browser_request_handler import BrowserRequestHandler
|
|
4
4
|
from .markdown_syncer import MarkdownSyncer
|
|
5
5
|
|
|
6
|
-
__all__ = ["
|
|
6
|
+
__all__ = ["BrowserRequestHandler", "MarkdownSyncer"]
|
ssb_pubmd/__main__.py
CHANGED
|
@@ -1,87 +1,102 @@
|
|
|
1
1
|
"""Command-line interface."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from dataclasses import dataclass
|
|
4
7
|
|
|
5
8
|
import click
|
|
6
9
|
|
|
7
|
-
from ssb_pubmd.
|
|
10
|
+
from ssb_pubmd.browser_request_handler import BrowserRequestHandler
|
|
11
|
+
from ssb_pubmd.browser_request_handler import CreateContextMethod
|
|
8
12
|
from ssb_pubmd.markdown_syncer import MarkdownSyncer
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
BASE_DIR = os.path.join(os.path.expanduser("~"), ".pubmd")
|
|
15
|
+
os.makedirs(BASE_DIR, exist_ok=True)
|
|
11
16
|
|
|
17
|
+
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
|
18
|
+
BROWSER_CONTEXT_PATH = os.path.join(BASE_DIR, "browser_context.json")
|
|
12
19
|
|
|
13
|
-
class ConfigKeys:
|
|
14
|
-
"""The keys used in the configuration file."""
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
@dataclass
|
|
22
|
+
class Config:
|
|
23
|
+
"""Handles the user configuration."""
|
|
18
24
|
|
|
25
|
+
login_url: str = ""
|
|
26
|
+
post_url: str = ""
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
with open(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
except FileNotFoundError:
|
|
27
|
-
click.echo(
|
|
28
|
-
f"Configuration file '{CONFIG_FILE}' not found. Please run the 'config' command first."
|
|
29
|
-
)
|
|
30
|
-
except Exception:
|
|
31
|
-
click.echo(
|
|
32
|
-
f"Error reading configuration file '{CONFIG_FILE}'. Please run the 'pubmd config' command again."
|
|
33
|
-
)
|
|
34
|
-
return str(value)
|
|
28
|
+
@classmethod
|
|
29
|
+
def load(cls, path: str) -> "Config":
|
|
30
|
+
"""Loads the configuration from a file."""
|
|
31
|
+
with open(path) as f:
|
|
32
|
+
data = json.load(f)
|
|
33
|
+
return cls(**data)
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Setup with subcommands 'config' and 'login', then use subcommand 'sync'.
|
|
42
|
-
"""
|
|
43
|
-
pass
|
|
35
|
+
def save(self, path: str) -> None:
|
|
36
|
+
"""Saves the configuration to a file."""
|
|
37
|
+
with open(path, "w") as f:
|
|
38
|
+
json.dump(asdict(self), f, indent=4)
|
|
44
39
|
|
|
45
40
|
|
|
46
41
|
@click.command()
|
|
47
42
|
def config() -> None:
|
|
48
|
-
"""
|
|
43
|
+
"""Set the login and post URL for the CMS."""
|
|
44
|
+
config_file = CONFIG_PATH
|
|
45
|
+
|
|
49
46
|
login_url = click.prompt("Enter the login URL", type=str)
|
|
50
47
|
post_url = click.prompt("Enter the post URL", type=str)
|
|
51
48
|
|
|
52
|
-
config =
|
|
49
|
+
config = Config(login_url=login_url, post_url=post_url)
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
json.dump(config, json_file, indent=4)
|
|
51
|
+
config.save(config_file)
|
|
56
52
|
|
|
57
|
-
click.echo(f"\
|
|
53
|
+
click.echo(f"\nConfiguration stored in:\n{config_file}")
|
|
58
54
|
|
|
59
55
|
|
|
60
56
|
@click.command()
|
|
61
57
|
def login() -> None:
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
"""Log in to the CMS application."""
|
|
59
|
+
config_file = CONFIG_PATH
|
|
60
|
+
browser_context_file = BROWSER_CONTEXT_PATH
|
|
61
|
+
|
|
62
|
+
config = Config.load(config_file)
|
|
63
|
+
request_handler = BrowserRequestHandler(browser_context_file, config.login_url)
|
|
64
|
+
|
|
65
|
+
method = CreateContextMethod.FROM_LOGIN
|
|
66
|
+
with request_handler.new_context(method=method):
|
|
67
|
+
click.echo("Logging in...")
|
|
68
|
+
|
|
69
|
+
click.echo(f"\nBrowser context stored in:\n{browser_context_file}")
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
@click.command()
|
|
70
73
|
@click.argument("content_file_path", type=click.Path())
|
|
71
74
|
def sync(content_file_path: str) -> None:
|
|
72
75
|
"""Sync a markdown or notebook file to the CMS."""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
request_context.recreate_from_file()
|
|
76
|
+
config_file = CONFIG_PATH
|
|
77
|
+
browser_context_file = BROWSER_CONTEXT_PATH
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
syncer.content_file_path = content_file_path
|
|
79
|
+
request_handler = BrowserRequestHandler(browser_context_file)
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
with request_handler.new_context():
|
|
82
|
+
config = Config.load(config_file)
|
|
83
|
+
syncer = MarkdownSyncer(
|
|
84
|
+
post_url=config.post_url, request_handler=request_handler
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
syncer.content_file_path = content_file_path
|
|
88
|
+
content_id = syncer.sync_content()
|
|
81
89
|
|
|
82
90
|
click.echo(
|
|
83
|
-
f"File '{click.format_filename(
|
|
91
|
+
f"File '{click.format_filename(browser_context_file)}' synced to CMS with content ID: {content_id}."
|
|
84
92
|
)
|
|
93
|
+
click.echo(f"Response data saved to '{click.format_filename(syncer.data_path)}'.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@click.group()
|
|
97
|
+
def cli() -> None:
|
|
98
|
+
"""Pubmd - a tool to sync markdown and notebook files to a CMS."""
|
|
99
|
+
pass
|
|
85
100
|
|
|
86
101
|
|
|
87
102
|
cli.add_command(config)
|
|
@@ -89,4 +104,4 @@ cli.add_command(login)
|
|
|
89
104
|
cli.add_command(sync)
|
|
90
105
|
|
|
91
106
|
if __name__ == "__main__":
|
|
92
|
-
cli()
|
|
107
|
+
cli()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from playwright.sync_api import BrowserContext
|
|
7
|
+
from playwright.sync_api import sync_playwright
|
|
8
|
+
|
|
9
|
+
from .markdown_syncer import Response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CreateContextMethod(Enum):
|
|
13
|
+
"""The method used to create the browser context.
|
|
14
|
+
|
|
15
|
+
Can be either from a file containing the context data,
|
|
16
|
+
or from a login popup window.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
FROM_FILE = "from_file"
|
|
20
|
+
FROM_LOGIN = "from_login"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BrowserRequestHandler:
|
|
24
|
+
"""This class is used to create a logged in browser context from which to send requests."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, context_file_path: str, login_url: str = "") -> None:
|
|
27
|
+
"""Initializes an empty browser context object."""
|
|
28
|
+
self._context_file_path: str = os.path.abspath(context_file_path)
|
|
29
|
+
self._login_url: str = login_url
|
|
30
|
+
self._context: BrowserContext | None = None
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def new_context(
|
|
34
|
+
self, method: CreateContextMethod = CreateContextMethod.FROM_FILE
|
|
35
|
+
) -> Iterator[BrowserContext]:
|
|
36
|
+
"""Wrapper around playwright's context manager.
|
|
37
|
+
|
|
38
|
+
The default is to create a new context from a file.
|
|
39
|
+
If `from_file` is set False, a new context is created through a browser popup with user login,
|
|
40
|
+
and the context is saved to a file.
|
|
41
|
+
"""
|
|
42
|
+
with sync_playwright() as playwright:
|
|
43
|
+
browser = playwright.chromium.launch(headless=False)
|
|
44
|
+
match method:
|
|
45
|
+
case CreateContextMethod.FROM_FILE:
|
|
46
|
+
self._context = browser.new_context(
|
|
47
|
+
storage_state=self._context_file_path
|
|
48
|
+
)
|
|
49
|
+
case CreateContextMethod.FROM_LOGIN:
|
|
50
|
+
self._context = browser.new_context()
|
|
51
|
+
login_page = self._context.new_page()
|
|
52
|
+
login_page.goto(self._login_url)
|
|
53
|
+
login_page.wait_for_event("close", timeout=0)
|
|
54
|
+
self._context.storage_state(path=self._context_file_path)
|
|
55
|
+
yield self._context
|
|
56
|
+
self._context.close()
|
|
57
|
+
browser.close()
|
|
58
|
+
|
|
59
|
+
def send_request(
|
|
60
|
+
self,
|
|
61
|
+
url: str,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
data: dict[str, str] | None = None,
|
|
64
|
+
) -> Response:
|
|
65
|
+
"""Sends a request to the specified url, optionally with headers and data, within the browser context."""
|
|
66
|
+
if self._context is None:
|
|
67
|
+
raise ValueError("Browser context has not been created.")
|
|
68
|
+
|
|
69
|
+
api_response = self._context.request.post(
|
|
70
|
+
url,
|
|
71
|
+
data=data,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
body = api_response.json()
|
|
76
|
+
body = dict(body)
|
|
77
|
+
except Exception:
|
|
78
|
+
body = None
|
|
79
|
+
|
|
80
|
+
response = Response(
|
|
81
|
+
status_code=api_response.status,
|
|
82
|
+
body=body,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return response
|
ssb_pubmd/markdown_syncer.py
CHANGED
|
@@ -18,8 +18,8 @@ class Response:
|
|
|
18
18
|
body: dict[str, Any] | None = None
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
class
|
|
22
|
-
"""Interface for the
|
|
21
|
+
class RequestHandler(Protocol):
|
|
22
|
+
"""Interface for the handling how the request is sent.
|
|
23
23
|
|
|
24
24
|
Implementing classes may handle authentication, sessions, etc.
|
|
25
25
|
"""
|
|
@@ -34,11 +34,11 @@ class RequestContext(Protocol):
|
|
|
34
34
|
...
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
class
|
|
38
|
-
"""Basic, unauthenticated request
|
|
37
|
+
class BasicRequestHandler:
|
|
38
|
+
"""Basic, unauthenticated request handler."""
|
|
39
39
|
|
|
40
40
|
def __init__(self) -> None:
|
|
41
|
-
"""Initializes the basic request
|
|
41
|
+
"""Initializes the basic request handler."""
|
|
42
42
|
pass
|
|
43
43
|
|
|
44
44
|
def send_request(
|
|
@@ -91,10 +91,10 @@ class MarkdownSyncer:
|
|
|
91
91
|
|
|
92
92
|
ID_KEY = "_id"
|
|
93
93
|
|
|
94
|
-
def __init__(self, post_url: str,
|
|
94
|
+
def __init__(self, post_url: str, request_handler: RequestHandler) -> None:
|
|
95
95
|
"""Creates a markdown syncer instance that connects to the CMS through the post url."""
|
|
96
96
|
self._post_url: str = post_url
|
|
97
|
-
self.
|
|
97
|
+
self._request_handler: RequestHandler = request_handler
|
|
98
98
|
self._content_file_path: str = ""
|
|
99
99
|
self._content_file_type: FileType = FileType.MARKDOWN
|
|
100
100
|
|
|
@@ -132,7 +132,7 @@ class MarkdownSyncer:
|
|
|
132
132
|
@property
|
|
133
133
|
def data_path(self) -> str:
|
|
134
134
|
"""The absolute path of the file to store the data returned from the CMS."""
|
|
135
|
-
return os.path.splitext(self.content_file_path)[0] + ".json"
|
|
135
|
+
return os.path.splitext(self.content_file_path)[0] + "-PUBMD.json"
|
|
136
136
|
|
|
137
137
|
@property
|
|
138
138
|
def display_name(self) -> str:
|
|
@@ -197,7 +197,7 @@ class MarkdownSyncer:
|
|
|
197
197
|
|
|
198
198
|
def _send_request(self) -> str:
|
|
199
199
|
"""Sends the request to the CMS endpoint and returns the content id from the response."""
|
|
200
|
-
response = self.
|
|
200
|
+
response = self._request_handler.send_request(
|
|
201
201
|
url=self._post_url, data=self._request_data()
|
|
202
202
|
)
|
|
203
203
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ssb-pubmd
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14
|
|
4
4
|
Summary: SSB Pubmd
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Olav Landsverk
|
|
@@ -51,10 +51,6 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
[black]: https://github.com/psf/black
|
|
52
52
|
[poetry]: https://python-poetry.org/
|
|
53
53
|
|
|
54
|
-
## Features
|
|
55
|
-
|
|
56
|
-
- Supports logging in through a popup browser window.
|
|
57
|
-
|
|
58
54
|
|
|
59
55
|
## Installation
|
|
60
56
|
|
|
@@ -67,7 +63,7 @@ pip install ssb-pubmd
|
|
|
67
63
|
If you need to create a logged-in browser context, you will also need to install a [Playwright browser](https://playwright.dev/python/docs/browsers#install-browsers):
|
|
68
64
|
|
|
69
65
|
```console
|
|
70
|
-
playwright install --with-deps chromium
|
|
66
|
+
playwright install --with-deps chromium
|
|
71
67
|
```
|
|
72
68
|
|
|
73
69
|
## Usage
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ssb_pubmd/__init__.py,sha256=hWhlVS_FDH9yq7fBdXwggg5opjZwf7gIKY6ZyJ81Y4w,176
|
|
2
|
+
ssb_pubmd/__main__.py,sha256=0Fo6rSeca4uQegIqD5At7I-3smeDwSoM4uG0YvWjK3U,2970
|
|
3
|
+
ssb_pubmd/browser_request_handler.py,sha256=mxUQD2_5pd3wy4z5im4XvdzRCknFjGDNEulflXWWWNs,2900
|
|
4
|
+
ssb_pubmd/markdown_syncer.py,sha256=LjwNap3OktkOqt8q-U1tBq2Q79Omygul2-qOBXSYaZQ,7651
|
|
5
|
+
ssb_pubmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ssb_pubmd-0.0.14.dist-info/LICENSE,sha256=tF5bnYv09fgH5ph9t1EpH1MGrVOGTQeswL4dzVeZ_ak,1073
|
|
7
|
+
ssb_pubmd-0.0.14.dist-info/METADATA,sha256=y_vWYiUtKeVEr7yHV_4D6pVi3hDJ5SPKKuRFgbJzzkI,4139
|
|
8
|
+
ssb_pubmd-0.0.14.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
|
9
|
+
ssb_pubmd-0.0.14.dist-info/entry_points.txt,sha256=1_NfsiOfqTg948JWXYPwi4QtDk90KHkNn1CQtye8rJ0,48
|
|
10
|
+
ssb_pubmd-0.0.14.dist-info/RECORD,,
|
ssb_pubmd/browser_context.py
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
from playwright.sync_api import BrowserContext
|
|
2
|
-
from playwright.sync_api import StorageState
|
|
3
|
-
from playwright.sync_api import sync_playwright
|
|
4
|
-
|
|
5
|
-
from .markdown_syncer import Response
|
|
6
|
-
|
|
7
|
-
BROWSER_CONTEXT_FILE = "pubmd_browser_context.json"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class BrowserRequestContext:
|
|
11
|
-
"""This class is used to create a logged in browser context from which to send requests."""
|
|
12
|
-
|
|
13
|
-
def __init__(self) -> None:
|
|
14
|
-
"""Initializes an empty browser context object."""
|
|
15
|
-
self._storage_state_path: str = BROWSER_CONTEXT_FILE
|
|
16
|
-
self._context: BrowserContext | None = None
|
|
17
|
-
|
|
18
|
-
def create_new(self, login_url: str) -> tuple[str, StorageState]:
|
|
19
|
-
"""Creates a browser context by opening a login page and waiting for it to be closed by user.
|
|
20
|
-
|
|
21
|
-
This function also saves the browser context to a file for later use.
|
|
22
|
-
"""
|
|
23
|
-
playwright = sync_playwright().start()
|
|
24
|
-
browser = playwright.chromium.launch(headless=False)
|
|
25
|
-
|
|
26
|
-
self._context = browser.new_context()
|
|
27
|
-
login_page = self._context.new_page()
|
|
28
|
-
|
|
29
|
-
login_page.goto(login_url)
|
|
30
|
-
login_page.wait_for_event("close", timeout=0)
|
|
31
|
-
|
|
32
|
-
storage_state = self._context.storage_state(path=self._storage_state_path)
|
|
33
|
-
|
|
34
|
-
return self._storage_state_path, storage_state
|
|
35
|
-
|
|
36
|
-
def recreate_from_file(self) -> BrowserContext:
|
|
37
|
-
"""Recreates a browser context object from a file."""
|
|
38
|
-
playwright = sync_playwright().start()
|
|
39
|
-
browser = playwright.chromium.launch(headless=False)
|
|
40
|
-
|
|
41
|
-
self._context = browser.new_context(storage_state=self._storage_state_path)
|
|
42
|
-
|
|
43
|
-
return self._context
|
|
44
|
-
|
|
45
|
-
def send_request(
|
|
46
|
-
self,
|
|
47
|
-
url: str,
|
|
48
|
-
headers: dict[str, str] | None = None,
|
|
49
|
-
data: dict[str, str] | None = None,
|
|
50
|
-
) -> Response:
|
|
51
|
-
"""Sends a request to the specified url, optionally with headers and data, within the browser context."""
|
|
52
|
-
if self._context is None:
|
|
53
|
-
raise ValueError("Browser context has not been created.")
|
|
54
|
-
|
|
55
|
-
api_response = self._context.request.post(
|
|
56
|
-
url,
|
|
57
|
-
params=data,
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
try:
|
|
61
|
-
body = api_response.json()
|
|
62
|
-
body = dict(body)
|
|
63
|
-
except Exception:
|
|
64
|
-
body = None
|
|
65
|
-
|
|
66
|
-
response = Response(
|
|
67
|
-
status_code=api_response.status,
|
|
68
|
-
body=body,
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
return response
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
ssb_pubmd/__init__.py,sha256=WD-U43QQTQIRSRgTHSnEjcG5MuH4T_CPFHPXols2NLk,179
|
|
2
|
-
ssb_pubmd/__main__.py,sha256=a99fscfnw6rr2fOG4KFsCzZnSzo2eo94xpX7vyLImV8,2611
|
|
3
|
-
ssb_pubmd/browser_context.py,sha256=zNT-YehZh00uG8f4KGr3KbXIFOE84_7Ky4HjOmxN4zs,2350
|
|
4
|
-
ssb_pubmd/markdown_syncer.py,sha256=RHP3bHIGPRLichrleygOS2WIVN1KOlYPehbrcenJMRk,7631
|
|
5
|
-
ssb_pubmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
ssb_pubmd-0.0.13.dist-info/LICENSE,sha256=tF5bnYv09fgH5ph9t1EpH1MGrVOGTQeswL4dzVeZ_ak,1073
|
|
7
|
-
ssb_pubmd-0.0.13.dist-info/METADATA,sha256=P05Hv5mkPm8z0dF63GMhfE7PZdUr4u8uWylv-WvvXWU,4208
|
|
8
|
-
ssb_pubmd-0.0.13.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
|
9
|
-
ssb_pubmd-0.0.13.dist-info/entry_points.txt,sha256=1_NfsiOfqTg948JWXYPwi4QtDk90KHkNn1CQtye8rJ0,48
|
|
10
|
-
ssb_pubmd-0.0.13.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|