ssb-pubmd 0.1.0__py3-none-any.whl → 0.1.2__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 +4 -0
- ssb_pubmd/__main__.py +2 -13
- ssb_pubmd/adapters/content_parser.py +185 -0
- ssb_pubmd/adapters/document_processor.py +149 -0
- ssb_pubmd/adapters/publish_client.py +117 -0
- ssb_pubmd/adapters/storage.py +42 -0
- ssb_pubmd/cli.py +102 -0
- ssb_pubmd/config.py +27 -17
- ssb_pubmd/domain/document_publisher.py +46 -0
- ssb_pubmd/notebook_client.py +133 -0
- ssb_pubmd/templates/__init__.py +0 -0
- ssb_pubmd/templates/template.qmd +138 -0
- {ssb_pubmd-0.1.0.dist-info → ssb_pubmd-0.1.2.dist-info}/METADATA +8 -5
- ssb_pubmd-0.1.2.dist-info/RECORD +18 -0
- ssb_pubmd/adapters/cli.py +0 -21
- ssb_pubmd/adapters/cms_client.py +0 -47
- ssb_pubmd/adapters/local_storage.py +0 -72
- ssb_pubmd/adapters/secret_manager_client.py +0 -66
- ssb_pubmd/enonic_cms_manager.py +0 -30
- ssb_pubmd/models.py +0 -28
- ssb_pubmd/ports.py +0 -57
- ssb_pubmd-0.1.0.dist-info/RECORD +0 -15
- {ssb_pubmd-0.1.0.dist-info → ssb_pubmd-0.1.2.dist-info}/LICENSE +0 -0
- {ssb_pubmd-0.1.0.dist-info → ssb_pubmd-0.1.2.dist-info}/WHEEL +0 -0
- {ssb_pubmd-0.1.0.dist-info → ssb_pubmd-0.1.2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from ssb_pubmd.adapters.content_parser import ContentParser
|
|
2
|
+
from ssb_pubmd.adapters.document_processor import DocumentProcessor
|
|
3
|
+
from ssb_pubmd.adapters.publish_client import PublishClient
|
|
4
|
+
from ssb_pubmd.adapters.storage import Storage
|
|
5
|
+
|
|
6
|
+
USER_KEY_PREFIX = "user:"
|
|
7
|
+
DOCUMENT_KEY = "app:document"
|
|
8
|
+
|
|
9
|
+
def sync_document(
|
|
10
|
+
raw_document_content: str,
|
|
11
|
+
document_processor: DocumentProcessor,
|
|
12
|
+
content_parser: ContentParser,
|
|
13
|
+
storage: Storage,
|
|
14
|
+
publish_client: PublishClient,
|
|
15
|
+
) -> str:
|
|
16
|
+
document_processor.load(raw_document_content)
|
|
17
|
+
|
|
18
|
+
document_metadata = document_processor.extract_metadata(target_key="ssb")
|
|
19
|
+
document_metadata["content_type"] = "article"
|
|
20
|
+
|
|
21
|
+
document_publish_path = storage.get(DOCUMENT_KEY).get("publish_path")
|
|
22
|
+
if not document_publish_path:
|
|
23
|
+
content = content_parser.parse(metadata=document_metadata, html=None)
|
|
24
|
+
response = publish_client.send_content(content)
|
|
25
|
+
storage.update(
|
|
26
|
+
DOCUMENT_KEY,
|
|
27
|
+
{"publish_id": response.publish_id, "publish_path": response.publish_path},
|
|
28
|
+
)
|
|
29
|
+
document_publish_path = response.publish_path
|
|
30
|
+
|
|
31
|
+
document_elements = document_processor.extract_elements(target_class="ssb")
|
|
32
|
+
for id_, html in document_elements:
|
|
33
|
+
key = USER_KEY_PREFIX + id_
|
|
34
|
+
metadata = storage.get(key) | {"publish_folder": document_publish_path}
|
|
35
|
+
component = content_parser.parse(metadata, html)
|
|
36
|
+
response = publish_client.send_content(component)
|
|
37
|
+
storage.update(key, {"publish_id": response.publish_id})
|
|
38
|
+
document_processor.replace_element(id_, response.publish_html)
|
|
39
|
+
|
|
40
|
+
article_metadata = document_metadata | {
|
|
41
|
+
"publish_id": storage.get(DOCUMENT_KEY).get("publish_id")
|
|
42
|
+
}
|
|
43
|
+
html = document_processor.extract_html()
|
|
44
|
+
article = content_parser.parse(metadata=article_metadata, html=html)
|
|
45
|
+
response = publish_client.send_content(article)
|
|
46
|
+
return response.publish_url
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import narwhals as nw
|
|
5
|
+
from narwhals.typing import IntoDataFrame
|
|
6
|
+
|
|
7
|
+
from ssb_pubmd.adapters.content_parser import Content
|
|
8
|
+
from ssb_pubmd.adapters.content_parser import ContentParser
|
|
9
|
+
from ssb_pubmd.adapters.content_parser import MimirContentParser
|
|
10
|
+
from ssb_pubmd.adapters.storage import LocalFileStorage
|
|
11
|
+
from ssb_pubmd.adapters.storage import Storage
|
|
12
|
+
from ssb_pubmd.domain.document_publisher import USER_KEY_PREFIX
|
|
13
|
+
|
|
14
|
+
STORAGE: Storage = LocalFileStorage(project_folder=Path.cwd())
|
|
15
|
+
CONTENT_PARSER: ContentParser = MimirContentParser()
|
|
16
|
+
|
|
17
|
+
class NotebookClientError(Exception): ...
|
|
18
|
+
|
|
19
|
+
def configure_factbox(
|
|
20
|
+
key: str,
|
|
21
|
+
title: str,
|
|
22
|
+
display_type: Literal["default", "sneakPeek", "aiIcon"] = "default",
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Oppretter en faktaboks og printer en Markdown-snippet som kan limes inn i artikkelen (på en ny linje).
|
|
25
|
+
|
|
26
|
+
:param key: En unik nøkkel for innholdet.
|
|
27
|
+
:param title: Tittelen til faktaboksen.
|
|
28
|
+
:param display_type: Visning av faktaboksen.
|
|
29
|
+
|
|
30
|
+
Alternativer:
|
|
31
|
+
|
|
32
|
+
* "default": Bare tittel (standard)
|
|
33
|
+
* "sneakPeek": Tittel og litt av forklaringsteksten
|
|
34
|
+
* "aiIcon": Tittel og litt av forklaringsteksten + KI-ikon
|
|
35
|
+
"""
|
|
36
|
+
metadata = {
|
|
37
|
+
"content_type": "factBox",
|
|
38
|
+
"title": title,
|
|
39
|
+
"display_type": display_type,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
content = CONTENT_PARSER.parse(
|
|
43
|
+
metadata=metadata,
|
|
44
|
+
html=None,
|
|
45
|
+
)
|
|
46
|
+
_store_user_content(user_key=key, content=content)
|
|
47
|
+
|
|
48
|
+
md = _get_markdown_snippet(
|
|
49
|
+
key,
|
|
50
|
+
placeholder_text="Faktaboksens tekst skrives her med Markdown-syntaks.\n\n### Underoverskrift\n\nNy paragraf.",
|
|
51
|
+
)
|
|
52
|
+
print(md)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_highchart(
|
|
56
|
+
key: str,
|
|
57
|
+
title: str,
|
|
58
|
+
dataframe: IntoDataFrame | None = None,
|
|
59
|
+
tbml: str | None = None,
|
|
60
|
+
graph_type: Literal["line", "pie", "column", "bar", "area", "barNegative"] = "line",
|
|
61
|
+
xlabel: str = "x",
|
|
62
|
+
ylabel: str = "y"
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Oppretter et highchart og printer en Markdown-snippet som kan limes inn i artikkelen (på en ny linje).
|
|
65
|
+
|
|
66
|
+
Som datakilde er det nødvendig å spesifisere enten `dataframe` eller `tbml`.
|
|
67
|
+
|
|
68
|
+
:param key: En unik nøkkel for innholdet.
|
|
69
|
+
:param title: Tittelen til highchartet.
|
|
70
|
+
:param dataframe: En pandas, Polars eller PyArrow dataframe.
|
|
71
|
+
:param tbml: URL eller TBML-id.
|
|
72
|
+
:param graph_type: Graftype.
|
|
73
|
+
|
|
74
|
+
Alternativer:
|
|
75
|
+
|
|
76
|
+
* "line": Linje (standard)
|
|
77
|
+
* "pie": Kake
|
|
78
|
+
* "column": Stolpe
|
|
79
|
+
* "bar": Liggende stolpe
|
|
80
|
+
* "area": Areal
|
|
81
|
+
* "barNegative": Pyramide
|
|
82
|
+
|
|
83
|
+
:param xlabel: X-akse, tittel.
|
|
84
|
+
:param ylabel: Y-akse, tittel.
|
|
85
|
+
"""
|
|
86
|
+
if dataframe is None and tbml is None:
|
|
87
|
+
raise NotebookClientError("Either 'dataframe' or 'tbml' must be specified.")
|
|
88
|
+
metadata = {
|
|
89
|
+
"content_type": "highchart",
|
|
90
|
+
"title": title,
|
|
91
|
+
"graph_type": graph_type,
|
|
92
|
+
"xlabel": xlabel,
|
|
93
|
+
"ylabel": ylabel
|
|
94
|
+
}
|
|
95
|
+
if tbml is not None:
|
|
96
|
+
metadata["tbml"] = tbml
|
|
97
|
+
html = _dataframe_to_html_table(dataframe) if dataframe is not None else None
|
|
98
|
+
content = CONTENT_PARSER.parse(metadata, html)
|
|
99
|
+
_store_user_content(user_key=key, content=content)
|
|
100
|
+
|
|
101
|
+
md = _get_markdown_snippet(key)
|
|
102
|
+
print(md)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _store_user_content(user_key: str, content: Content) -> None:
|
|
106
|
+
key = USER_KEY_PREFIX + str(user_key)
|
|
107
|
+
STORAGE.update(key, content.to_dict())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _dataframe_to_html_table(dataframe: IntoDataFrame) -> str:
|
|
111
|
+
df = nw.from_native(dataframe)
|
|
112
|
+
html = "<table><tbody>\n"
|
|
113
|
+
|
|
114
|
+
html += "<tr>\n"
|
|
115
|
+
for name in df.columns:
|
|
116
|
+
html += f" <td>{name}</td>\n"
|
|
117
|
+
html += "</tr>\n"
|
|
118
|
+
|
|
119
|
+
for row in df.iter_rows():
|
|
120
|
+
html += " <tr>\n"
|
|
121
|
+
for value in row:
|
|
122
|
+
html += f" <td>{value}</td>\n"
|
|
123
|
+
html += " </tr>\n"
|
|
124
|
+
|
|
125
|
+
html += "</tbody></table>"
|
|
126
|
+
|
|
127
|
+
return html
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _get_markdown_snippet(key: str, placeholder_text: str | None = None) -> str:
|
|
131
|
+
div_config = f"{{ #{key} .ssb }}"
|
|
132
|
+
div_content = f"\n{placeholder_text}\n\n" if placeholder_text is not None else ""
|
|
133
|
+
return f"::: {div_config}\n{div_content}:::"
|
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
ssb:
|
|
3
|
+
path: /utdanning/grunnskoler/statistikk/elevar-i-grunnskolen/artikler
|
|
4
|
+
title: "Hovedtittel for artikkelen"
|
|
5
|
+
authors:
|
|
6
|
+
- name: Fornavn Etternavn
|
|
7
|
+
email: ---@ssb.no
|
|
8
|
+
- name: Fornavn Etternavn
|
|
9
|
+
email: ---@ssb.no
|
|
10
|
+
ingress: |
|
|
11
|
+
Ingress for artikkelen,
|
|
12
|
+
som kan skrives over flere linjer.
|
|
13
|
+
---
|
|
14
|
+
Dette er en SSB-artikkel skrevet med Quarto Markdown.
|
|
15
|
+
|
|
16
|
+
Neste steg:
|
|
17
|
+
|
|
18
|
+
1. Sjekk at du er logget inn i CMS-et: {{ cms_admin_url }}/tool/com.enonic.app.contentstudio/main.
|
|
19
|
+
|
|
20
|
+
2. Det er gitt et eksempel på CMS-mappe i feltet `path` ovenfor. Endre denne til CMS-mappen du ønsker å opprette artikkelen i. Dette må være en mappe du har skrivetilgang til.
|
|
21
|
+
|
|
22
|
+
3. Kjør kommandoen `poetry run ssb-pubmd preview {{ filepath }}`.
|
|
23
|
+
|
|
24
|
+
* Dette vil opprette en draft-artikkel og printe URL'en til en forhåndsvisning av artikkelen.
|
|
25
|
+
* Kommandoen fungerer tilsvarende som `quarto preview`, det vil si at forhåndsvisningen holdes oppdatert etter hvert som du endrer og lagrer denne filen.****
|
|
26
|
+
* Du må oppdatere nettleservinduet for å se endringene.
|
|
27
|
+
|
|
28
|
+
4. Les seksjonene nedenfor for en kort introduksjon til skriving av SSB-artikler i Quarto Markdown.
|
|
29
|
+
|
|
30
|
+
## Artikkelmetadata
|
|
31
|
+
|
|
32
|
+
Metadata-blokken øverst viser all metadata som kan settes for en artikkel.
|
|
33
|
+
Dette er noe begrenset i testversjonen, men vil senere være komplett.
|
|
34
|
+
Merk at `path` og `title` er obligatoriske felt.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
## Markdown
|
|
38
|
+
|
|
39
|
+
Se [Quarto-referansen](https://quarto.org/docs/authoring/markdown-basics.html). Merk at:
|
|
40
|
+
|
|
41
|
+
* Hovedoverskrift (`#`) støttes ikke, og skal settes i metadatablokken.
|
|
42
|
+
|
|
43
|
+
## Kode
|
|
44
|
+
|
|
45
|
+
Se [Quarto-referansen](https://quarto.org/docs/computations/python.html). Merk at:
|
|
46
|
+
|
|
47
|
+
* Ingen kodeblokker eller output vil komme med i artikkelen.
|
|
48
|
+
* [Kode i teksten](https://quarto.org/docs/computations/inline-code.html) støttes. Koden vil evalueres og erstattes med tekst før artikkelen sendes til CMS'et.
|
|
49
|
+
* All kode må kunne kjøres fra topp til bunn.
|
|
50
|
+
|
|
51
|
+
## SSB-komponenter
|
|
52
|
+
|
|
53
|
+
I testversjonen tilbys et begrenset utvalg SSB-komponenter.
|
|
54
|
+
Komponentene opprettes med Python og settes inn med Markdown.
|
|
55
|
+
|
|
56
|
+
### Opprette komponenter
|
|
57
|
+
|
|
58
|
+
Importér Python-pakken:
|
|
59
|
+
|
|
60
|
+
```{python}
|
|
61
|
+
import ssb_pubmd as ssb
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Kodeblokkene nedenfor viser opprettelse av to komponenter; et highchart og en faktaboks:
|
|
65
|
+
```{python}
|
|
66
|
+
import pandas as pd
|
|
67
|
+
data = {
|
|
68
|
+
"År": [
|
|
69
|
+
2000, 2001, 2002, 2003, 2004,
|
|
70
|
+
2005, 2006, 2007, 2008, 2009,
|
|
71
|
+
2010, 2011, 2012, 2013, 2014,
|
|
72
|
+
2015, 2016, 2017, 2018, 2019
|
|
73
|
+
],
|
|
74
|
+
"Andel": [
|
|
75
|
+
4, 3.3, 4.7, 5.6, 7.2,
|
|
76
|
+
6.7, 7, 7.3, 7.6, 7.7,
|
|
77
|
+
7.7, 7.6, 8, 8.6, 9.4,
|
|
78
|
+
10, 10.3, 10.7, 11.3, 11.7
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
df = pd.DataFrame(data)
|
|
82
|
+
|
|
83
|
+
ssb.Highchart(
|
|
84
|
+
key="my-highchart",
|
|
85
|
+
title="Highchart title",
|
|
86
|
+
dataframe=df,
|
|
87
|
+
graph_type="line",
|
|
88
|
+
xlabel="my_xlabel",
|
|
89
|
+
ylabel="my_ylabel"
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```{python}
|
|
94
|
+
ssb.Factbox(
|
|
95
|
+
key = "my-factbox",
|
|
96
|
+
title = "Factbox title",
|
|
97
|
+
display_type = "default")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Dokumentasjon: (link kommer).
|
|
101
|
+
* For å se alle funksjonene som tilbys, skriv `ssb.` på en kodelinje og tast `Ctrl + Space` (VSCode) eller `Tab` (Jupyterlab).
|
|
102
|
+
* VSCode: Dokumentasjonen til funksjonen vises automatisk i en popup-boks når man hovrer over funksjonen.
|
|
103
|
+
|
|
104
|
+
### Sette inn komponenter
|
|
105
|
+
|
|
106
|
+
Når du kjører en av kodeblokkene ovenfor, vil det printes en Markdown-snippet som kan kopieres og limes inn på en ny linje:
|
|
107
|
+
|
|
108
|
+
::: { #my-highchart .ssb }
|
|
109
|
+
:::
|
|
110
|
+
|
|
111
|
+
Merk at faktabokser settes inn med innhold:
|
|
112
|
+
|
|
113
|
+
::: { #my-factbox .ssb }
|
|
114
|
+
|
|
115
|
+
Faktaboksens tekst skrives her med Markdown-syntaks.
|
|
116
|
+
|
|
117
|
+
### Underoverskrift
|
|
118
|
+
|
|
119
|
+
Ny paragraf.
|
|
120
|
+
|
|
121
|
+
:::
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## Synkronisering
|
|
125
|
+
|
|
126
|
+
Etter å ha synkronisert denne filen til CMS'et, vil du se en ekstra fil i Dapla-tjenesten:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
{{ project_folder }}/
|
|
130
|
+
├── {{ filename }}
|
|
131
|
+
├── .ssbno.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Metadatafilen `.ssbno.json` er automatisk generert og inneholder metadata for den tilhørende CMS-artikkelen. Merk at:
|
|
135
|
+
|
|
136
|
+
* Dersom `.ssbno.json` slettes, vil en ny CMS-artikkel opprettes ved neste synkronisering.
|
|
137
|
+
* Dersom man jobber med to separate SSB-artikler, skal disse ligge i hver sin mappe, slik at de har hver sin metadatafil.
|
|
138
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ssb-pubmd
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: SSB Pubmd
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Olav Landsverk
|
|
@@ -13,12 +13,15 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Requires-Dist:
|
|
17
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: dapla-auth-client (>=1.2.5,<2.0.0)
|
|
17
|
+
Requires-Dist: ipynbname (>=2025.8.0.0,<2026.0.0.0)
|
|
18
|
+
Requires-Dist: narwhals (>=2.15.0,<3.0.0)
|
|
18
19
|
Requires-Dist: nbformat (>=5.10.4,<6.0.0)
|
|
19
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: nh3 (>=0.3.2,<0.4.0)
|
|
21
|
+
Requires-Dist: pandocfilters (>=1.5.1,<2.0.0)
|
|
22
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
20
23
|
Requires-Dist: requests (>=2.32.4,<3.0.0)
|
|
21
|
-
Requires-Dist:
|
|
24
|
+
Requires-Dist: watchfiles (>=1.1.1,<2.0.0)
|
|
22
25
|
Project-URL: Changelog, https://github.com/statisticsnorway/ssb-pubmd/releases
|
|
23
26
|
Project-URL: Documentation, https://statisticsnorway.github.io/ssb-pubmd
|
|
24
27
|
Project-URL: Homepage, https://github.com/statisticsnorway/ssb-pubmd
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ssb_pubmd/__init__.py,sha256=GmZebzEEIJcwwYD6_J5irpY5-lGAKFr8lRrTGB_nAPA,170
|
|
2
|
+
ssb_pubmd/__main__.py,sha256=7Trn-DZkNbVn6s5J8FVg9JFtpFEHv-4VKZ34MVs23Cc,204
|
|
3
|
+
ssb_pubmd/adapters/content_parser.py,sha256=ExOULfwoFBVb9h2wH8m61fWFy192ZEIG2LI3fuFRpbE,5141
|
|
4
|
+
ssb_pubmd/adapters/document_processor.py,sha256=GN4FJmWcyiCdTBzWkyUReFGd7AME-F4Eq5N4JmarddQ,4271
|
|
5
|
+
ssb_pubmd/adapters/publish_client.py,sha256=5JIVtJCV_uLDv6YwGitQLE_T4dLWaWQCm9nO6F9RpR4,3350
|
|
6
|
+
ssb_pubmd/adapters/storage.py,sha256=Dexfgw0csQ9wljC6lqf9kFmoM2CHdfMghm-qBrgdWjM,1227
|
|
7
|
+
ssb_pubmd/cli.py,sha256=fOJKHz8l871k5rS-1b1IUzgnhJozMhiujXwzqyeL6og,3262
|
|
8
|
+
ssb_pubmd/config.py,sha256=FbHkHDobBdqZLT9ZZKfH9Ch0wtj4TmKNF8EMCbQAJgE,844
|
|
9
|
+
ssb_pubmd/domain/document_publisher.py,sha256=hgzJx9kGZJOVLrgFRg-JGAmNsasvHA9_BA1g3htcWrQ,1920
|
|
10
|
+
ssb_pubmd/notebook_client.py,sha256=w4Jlo6CH5tVTzSxZlD0bPn0CPwgdX2NL7nKzlfstheU,4090
|
|
11
|
+
ssb_pubmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
ssb_pubmd/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
ssb_pubmd/templates/template.qmd,sha256=s4aEMlseEyZMytHrNz8HpcYd1JG2UPwTOL_edJ48qmQ,4084
|
|
14
|
+
ssb_pubmd-0.1.2.dist-info/LICENSE,sha256=tF5bnYv09fgH5ph9t1EpH1MGrVOGTQeswL4dzVeZ_ak,1073
|
|
15
|
+
ssb_pubmd-0.1.2.dist-info/METADATA,sha256=NsDUsihthkrVF4tv7wXzp4e6JTD6gvOx_nbim_w1PLk,4101
|
|
16
|
+
ssb_pubmd-0.1.2.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
17
|
+
ssb_pubmd-0.1.2.dist-info/entry_points.txt,sha256=o4oU99zbZNIBKGYWdgdEG6ev-62ZRWEJOe7EOjJaajk,53
|
|
18
|
+
ssb_pubmd-0.1.2.dist-info/RECORD,,
|
ssb_pubmd/adapters/cli.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
|
|
4
|
-
from ssb_pubmd.ports import CmsManager
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@dataclass
|
|
8
|
-
class CliAdapter:
|
|
9
|
-
cms_manager: CmsManager
|
|
10
|
-
|
|
11
|
-
def run(self, system_arguments: list[str]) -> None:
|
|
12
|
-
match system_arguments:
|
|
13
|
-
case [_, "sync", file_path]:
|
|
14
|
-
preview = self.cms_manager.sync(file_path)
|
|
15
|
-
print(f"Preview URL: {preview}")
|
|
16
|
-
# except Exception as e:
|
|
17
|
-
# print(f"Error during sync: {e}")
|
|
18
|
-
# sys.exit(1)
|
|
19
|
-
case _:
|
|
20
|
-
print("Usage: ssb-pubmd sync <content_file_path>")
|
|
21
|
-
sys.exit(1)
|
ssb_pubmd/adapters/cms_client.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
from urllib.parse import urlparse
|
|
2
|
-
|
|
3
|
-
import requests
|
|
4
|
-
|
|
5
|
-
from ssb_pubmd.models import CmsResponse
|
|
6
|
-
from ssb_pubmd.models import Content
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class CmsClientError(Exception): ...
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class MimirCmsClient:
|
|
13
|
-
base_url: str
|
|
14
|
-
|
|
15
|
-
def __init__(self, base_url: str) -> None:
|
|
16
|
-
self.base_url = base_url
|
|
17
|
-
|
|
18
|
-
def _convert_preview_url(self, url_from_response: str) -> str:
|
|
19
|
-
"""Convert the preview URL to a full URL if it's relative."""
|
|
20
|
-
url = urlparse(url_from_response)
|
|
21
|
-
if url.scheme and url.netloc:
|
|
22
|
-
return url.geturl()
|
|
23
|
-
else:
|
|
24
|
-
return urlparse(self.base_url)._replace(path=url.path).geturl()
|
|
25
|
-
|
|
26
|
-
def send(self, token: str, content: Content) -> CmsResponse:
|
|
27
|
-
"""Sends a request to the Enonic CMS, assumed to have the mimir application installed (currently this only works with the feature branch https://github.com/statisticsnorway/mimir/pull/3192)."""
|
|
28
|
-
try:
|
|
29
|
-
response = requests.post(
|
|
30
|
-
f"{self.base_url}/_/service/mimir/postMarkdown",
|
|
31
|
-
headers={
|
|
32
|
-
"Authorization": f"Bearer {token}",
|
|
33
|
-
"Content-Type": "application/json",
|
|
34
|
-
},
|
|
35
|
-
json=content.to_json(),
|
|
36
|
-
)
|
|
37
|
-
if response.status_code != 200:
|
|
38
|
-
raise CmsClientError(
|
|
39
|
-
f"Request to CMS failed with status code {response.status_code}."
|
|
40
|
-
)
|
|
41
|
-
body = response.json()
|
|
42
|
-
return CmsResponse(
|
|
43
|
-
id=body["_id"],
|
|
44
|
-
preview_url=self._convert_preview_url(body["previewPath"]),
|
|
45
|
-
)
|
|
46
|
-
except Exception as e:
|
|
47
|
-
raise CmsClientError("Request to CMS failed.") from e
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import nbformat
|
|
6
|
-
|
|
7
|
-
from ssb_pubmd.models import Content
|
|
8
|
-
|
|
9
|
-
ID_KEY = "_id"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class LocalStorageError(Exception): ...
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass
|
|
16
|
-
class LocalStorageAdapter:
|
|
17
|
-
metadata_file_path: Path
|
|
18
|
-
|
|
19
|
-
def get_file_id(self, file_path: Path) -> str:
|
|
20
|
-
"""
|
|
21
|
-
Returns the content id of a given file path.
|
|
22
|
-
If no id is registered for the given file path,
|
|
23
|
-
it returns an empty string.
|
|
24
|
-
"""
|
|
25
|
-
with open(self.metadata_file_path) as metadata_file:
|
|
26
|
-
metadata = json.load(metadata_file)
|
|
27
|
-
return str(metadata.get(str(file_path.absolute()), {}).get(ID_KEY, ""))
|
|
28
|
-
|
|
29
|
-
def set_file_id(self, file_path: Path, content_id: str) -> None:
|
|
30
|
-
"""Stores a given file's content id in the metadata file"""
|
|
31
|
-
with open(self.metadata_file_path) as metadata_file:
|
|
32
|
-
metadata = json.load(metadata_file)
|
|
33
|
-
metadata[str(file_path.absolute())] = {ID_KEY: content_id}
|
|
34
|
-
with open(self.metadata_file_path, "w") as metadata_file:
|
|
35
|
-
json.dump(metadata, metadata_file)
|
|
36
|
-
|
|
37
|
-
def get_content(self, file_path: Path) -> Content:
|
|
38
|
-
"""
|
|
39
|
-
Returns the markdown content of a given file.
|
|
40
|
-
If the file is neither a .md or a .ipynb file,
|
|
41
|
-
this function will throw a `LocalStorageError`
|
|
42
|
-
"""
|
|
43
|
-
return Content(
|
|
44
|
-
content_id=self.get_file_id(file_path),
|
|
45
|
-
file_path=file_path,
|
|
46
|
-
markdown=self._get_content(file_path),
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
def _get_content_from_notebook_file(self, file_path: Path) -> str:
|
|
50
|
-
"""Extracts all markdown cells from the notebook and returns it as a string."""
|
|
51
|
-
notebook = nbformat.read(file_path, as_version=nbformat.NO_CONVERT) # type: ignore
|
|
52
|
-
markdown_cells = []
|
|
53
|
-
for cell in notebook.cells:
|
|
54
|
-
if cell.cell_type == "markdown":
|
|
55
|
-
markdown_cells.append(cell.source)
|
|
56
|
-
sep = "\n\n"
|
|
57
|
-
return sep.join(markdown_cells)
|
|
58
|
-
|
|
59
|
-
def _get_content_from_markdown_file(self, file_path: Path) -> str:
|
|
60
|
-
"""Returns the content of a markdown file as a string."""
|
|
61
|
-
with open(file_path) as file:
|
|
62
|
-
return file.read()
|
|
63
|
-
|
|
64
|
-
def _get_content(self, file_path: Path) -> str:
|
|
65
|
-
file_type = file_path.suffix
|
|
66
|
-
match file_type:
|
|
67
|
-
case ".md":
|
|
68
|
-
return self._get_content_from_markdown_file(file_path)
|
|
69
|
-
case ".ipynb":
|
|
70
|
-
return self._get_content_from_notebook_file(file_path)
|
|
71
|
-
case _:
|
|
72
|
-
raise LocalStorageError(f"Unsupported file type: {file_type}")
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
"""This module handles HTTP requests and responses to and from the CMS."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
|
|
7
|
-
import jwt
|
|
8
|
-
from google.cloud import secretmanager
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass
|
|
12
|
-
class Secret:
|
|
13
|
-
private_key: str
|
|
14
|
-
kid: str
|
|
15
|
-
principal_key: str
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class SecretManagerError(Exception): ...
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class GoogleSecretManagerClient:
|
|
22
|
-
TYPE = "JWT"
|
|
23
|
-
ALGORITHM = "RS256"
|
|
24
|
-
_gc_secret_resource_name: str
|
|
25
|
-
|
|
26
|
-
def __init__(self, gc_secret_resource_name: str) -> None:
|
|
27
|
-
self._gc_secret_resource_name = gc_secret_resource_name
|
|
28
|
-
|
|
29
|
-
def _get_secret(self) -> Secret:
|
|
30
|
-
"""Fetches the private key and related data from Google Cloud Secret Manager."""
|
|
31
|
-
client = secretmanager.SecretManagerServiceClient()
|
|
32
|
-
response = client.access_secret_version(name=self._gc_secret_resource_name)
|
|
33
|
-
raw_data = response.payload.data.decode("UTF-8")
|
|
34
|
-
data = json.loads(raw_data)
|
|
35
|
-
try:
|
|
36
|
-
return Secret(
|
|
37
|
-
private_key=data["privateKey"],
|
|
38
|
-
kid=data["kid"],
|
|
39
|
-
principal_key=data["principalKey"],
|
|
40
|
-
)
|
|
41
|
-
except KeyError as e:
|
|
42
|
-
raise SecretManagerError(
|
|
43
|
-
"The secret must be a JSON object with keys 'privateKey', 'kid' and 'principalKey'."
|
|
44
|
-
) from e
|
|
45
|
-
|
|
46
|
-
def generate_token(self) -> str:
|
|
47
|
-
secret = self._get_secret()
|
|
48
|
-
|
|
49
|
-
header = {
|
|
50
|
-
"kid": secret.kid,
|
|
51
|
-
"typ": self.TYPE,
|
|
52
|
-
"alg": self.ALGORITHM,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
iat = int(datetime.now().timestamp())
|
|
56
|
-
exp = iat + 30
|
|
57
|
-
payload = {
|
|
58
|
-
"sub": secret.principal_key,
|
|
59
|
-
"iat": iat,
|
|
60
|
-
"exp": exp,
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
token = jwt.encode(
|
|
64
|
-
payload, secret.private_key, algorithm=self.ALGORITHM, headers=header
|
|
65
|
-
)
|
|
66
|
-
return token
|
ssb_pubmd/enonic_cms_manager.py
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from ssb_pubmd.config import Config
|
|
5
|
-
from ssb_pubmd.ports import CmsClient
|
|
6
|
-
from ssb_pubmd.ports import ContentFileHandler
|
|
7
|
-
from ssb_pubmd.ports import SecretManagerClient
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class EnonicCmsManager:
|
|
12
|
-
"""A CMS Mananager tailored to the Enonic CMS."""
|
|
13
|
-
|
|
14
|
-
config: Config
|
|
15
|
-
cms_client: CmsClient
|
|
16
|
-
secret_manager_client: SecretManagerClient
|
|
17
|
-
content_file_handler: ContentFileHandler
|
|
18
|
-
|
|
19
|
-
def sync(self, content_file_path: str) -> str:
|
|
20
|
-
"""Requests that Enonic stores/updates the given contant file and gives back a rendered preview.
|
|
21
|
-
|
|
22
|
-
The details of the communication are handled by the CmsClient implementation, which in turn depends on the services that are exposed by the Enonic XP application. The only thing this class cares is that it receives a CmsResponse object, which contains an id and preview url of the content.
|
|
23
|
-
"""
|
|
24
|
-
content = self.content_file_handler.get_content(Path(content_file_path))
|
|
25
|
-
response = self.cms_client.send(
|
|
26
|
-
token=self.secret_manager_client.generate_token(),
|
|
27
|
-
content=content,
|
|
28
|
-
)
|
|
29
|
-
self.content_file_handler.set_file_id(content.file_path, response.id)
|
|
30
|
-
return response.preview_url
|
ssb_pubmd/models.py
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
@dataclass
|
|
6
|
-
class CmsResponse:
|
|
7
|
-
id: str
|
|
8
|
-
preview_url: str
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass
|
|
12
|
-
class Content:
|
|
13
|
-
content_id: str
|
|
14
|
-
file_path: Path
|
|
15
|
-
markdown: str
|
|
16
|
-
|
|
17
|
-
@property
|
|
18
|
-
def display_name(self) -> str:
|
|
19
|
-
"""Generate a display name for the content."""
|
|
20
|
-
return self.file_path.stem.replace("_", " ").title()
|
|
21
|
-
|
|
22
|
-
def to_json(self) -> dict[str, str]:
|
|
23
|
-
"""Returns a json representation of the content."""
|
|
24
|
-
return {
|
|
25
|
-
"_id": self.content_id,
|
|
26
|
-
"displayName": self.display_name,
|
|
27
|
-
"markdown": self.markdown,
|
|
28
|
-
}
|