11import asyncio
22
3+ from pathlib import Path
4+ from typing import get_args
5+
36import click
47
8+ from beanie import PydanticObjectId
9+ from beanie .operators import Eq , In
10+
511from tekst .config import get_config
612from tekst .db import init_odm , migrations
13+ from tekst .errors import TekstHTTPException
14+ from tekst .models .resource import (
15+ ResourceBaseDocument ,
16+ ResourceExportFormat ,
17+ res_exp_fmt_info ,
18+ )
719from tekst .openapi import generate_openapi_json
820from tekst .platform import bootstrap as app_bootstrap
921from tekst .platform import cleanup_task
1022from tekst .resources import call_resource_precompute_hooks
23+ from tekst .routers .resources import export_resource_contents_task
1124from tekst .search import create_indices_task
1225
1326
1629"""
1730
1831
19- async def _prepare_odm () -> None :
20- await init_odm ()
21-
22-
2332async def _create_indices () -> None :
24- await _prepare_odm ()
33+ await init_odm ()
2534 await create_indices_task ()
2635
2736
2837async def _refresh_precomputed_cache () -> None :
29- await _prepare_odm ()
38+ await init_odm ()
3039 await call_resource_precompute_hooks ()
3140
3241
3342async def _cleanup () -> None :
34- await _prepare_odm ()
43+ await init_odm ()
3544 await cleanup_task ()
3645
3746
3847async def _maintenance () -> None :
39- await _prepare_odm ()
48+ await init_odm ()
4049 await create_indices_task ()
4150 await call_resource_precompute_hooks ()
4251 await cleanup_task ()
4352
4453
54+ async def _export (
55+ resource_ids : list [PydanticObjectId ] | None ,
56+ * ,
57+ formats : list [ResourceExportFormat ],
58+ output_dir_path : Path ,
59+ quiet : bool = False ,
60+ delete : bool = False ,
61+ ) -> None :
62+ # check output path
63+ if not output_dir_path .exists ():
64+ output_dir_path .mkdir (parents = True )
65+ if not output_dir_path .is_dir ():
66+ click .echo (f"Output directory { output_dir_path } is not a directory" , err = True )
67+ exit (1 )
68+ # delete all existing files in output directory
69+ if delete :
70+ if not quiet :
71+ click .echo (f"Deleting all existing files in { output_dir_path } ..." )
72+ for child in output_dir_path .iterdir ():
73+ if child .is_file ():
74+ child .unlink ()
75+ # prepare system
76+ await init_odm ()
77+ await call_resource_precompute_hooks ()
78+ # get IDs of resources to export
79+ target_resources = await ResourceBaseDocument .find (
80+ Eq (ResourceBaseDocument .public , True ),
81+ In (ResourceBaseDocument .id , resource_ids ) if resource_ids else {},
82+ with_children = True ,
83+ ).to_list ()
84+ # give feedback on resources that could not be found
85+ for res in resource_ids or []:
86+ if res not in [res .id for res in target_resources ]:
87+ click .echo (f"Resource ID { res } not found or not public" , err = True )
88+ # run exports
89+ if not target_resources :
90+ click .echo ("No resources to export" , err = True )
91+ exit (1 )
92+ cfg = get_config ()
93+ for res in target_resources :
94+ res_id_str = str (res .id )
95+ for fmt in formats :
96+ if not quiet :
97+ click .echo (f"Exporting resource { res_id_str } as { fmt } ..." )
98+ try :
99+ export_props = await export_resource_contents_task (
100+ user = None ,
101+ cfg = cfg ,
102+ resource_id = res .id ,
103+ export_format = fmt ,
104+ )
105+ except TekstHTTPException as e :
106+ if e .detail .detail .key == "unsupportedExportFormat" :
107+ click .echo (
108+ f"Resource { res_id_str } does not support export format { fmt } " ,
109+ err = True ,
110+ )
111+ continue
112+ else :
113+ raise
114+ # move exported file to output directory
115+ source_path = cfg .temp_files_dir / export_props ["artifact" ]
116+ target_ext = res_exp_fmt_info [fmt ]["extension" ]
117+ target_path = output_dir_path / f"{ res_id_str } _{ fmt } .{ target_ext } "
118+ source_path .replace (target_path )
119+ if not quiet :
120+ click .echo (f"Exported resource { res_id_str } as { str (target_path )} ." )
121+
122+
45123@click .command ()
46124def bootstrap ():
47125 """Runs the Tekst initial bootstrap procedure"""
@@ -159,6 +237,69 @@ def schema(
159237 click .echo (schema_str )
160238
161239
240+ @click .command ()
241+ @click .option (
242+ "--resource" ,
243+ "-r" ,
244+ required = False ,
245+ default = None ,
246+ show_default = True ,
247+ multiple = True ,
248+ help = "ID(s) of resource(s) to export, all if not set" ,
249+ )
250+ @click .option (
251+ "--format" ,
252+ "-f" ,
253+ required = False ,
254+ default = get_args (ResourceExportFormat .__value__ ),
255+ show_default = True ,
256+ multiple = True ,
257+ help = "Format(s) to export, all if not set" ,
258+ )
259+ @click .option (
260+ "--output" ,
261+ "-o" ,
262+ required = False ,
263+ default = "/tmp/tekst_resource_export/" ,
264+ show_default = True ,
265+ help = "Output directory to write to" ,
266+ )
267+ @click .option (
268+ "--quiet" ,
269+ "-q" ,
270+ is_flag = True ,
271+ default = False ,
272+ help = "Don't output anything (except errors and warnings)" ,
273+ )
274+ @click .option (
275+ "--delete" ,
276+ "-d" ,
277+ is_flag = True ,
278+ default = False ,
279+ help = "Delete all existing files in the output directory" ,
280+ )
281+ def export (
282+ resource : list [str ] | None ,
283+ format : list [ResourceExportFormat ],
284+ output : str ,
285+ quiet : bool = False ,
286+ delete : bool = False ,
287+ ) -> None :
288+ """
289+ Exports the contents of the given resources (or all)
290+ using the given formats (or all) to the given output directory
291+ """
292+ asyncio .run (
293+ _export (
294+ [PydanticObjectId (res_id ) for res_id in resource ] if resource else None ,
295+ formats = format ,
296+ output_dir_path = Path (output ),
297+ quiet = quiet ,
298+ delete = delete ,
299+ )
300+ )
301+
302+
162303@click .group ()
163304def cli ():
164305 """Command line interface to the main functionalities of Tekst server"""
@@ -173,6 +314,7 @@ def cli():
173314cli .add_command (maintenance )
174315cli .add_command (migrate )
175316cli .add_command (schema )
317+ cli .add_command (export )
176318
177319
178320if __name__ == "__main__" :
0 commit comments