kash-shell 0.3.13__py3-none-any.whl → 0.3.15__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.
- kash/actions/core/markdownify.py +7 -4
- kash/actions/core/readability.py +4 -3
- kash/actions/core/render_as_html.py +2 -2
- kash/actions/core/show_webpage.py +2 -2
- kash/commands/workspace/selection_commands.py +22 -0
- kash/commands/workspace/workspace_commands.py +45 -22
- kash/exec/action_exec.py +25 -7
- kash/exec/precondition_registry.py +5 -3
- kash/exec/preconditions.py +31 -6
- kash/exec/resolve_args.py +2 -2
- kash/file_storage/file_store.py +48 -12
- kash/model/items_model.py +13 -8
- kash/model/operations_model.py +14 -0
- kash/shell/utils/native_utils.py +2 -2
- kash/utils/common/url.py +80 -3
- kash/utils/file_utils/file_formats.py +3 -2
- kash/utils/file_utils/file_formats_model.py +37 -49
- kash/utils/text_handling/doc_normalization.py +7 -0
- kash/web_content/local_file_cache.py +28 -5
- kash/web_gen/templates/base_styles.css.jinja +7 -3
- {kash_shell-0.3.13.dist-info → kash_shell-0.3.15.dist-info}/METADATA +1 -1
- {kash_shell-0.3.13.dist-info → kash_shell-0.3.15.dist-info}/RECORD +25 -26
- kash/workspaces/workspace_importing.py +0 -56
- {kash_shell-0.3.13.dist-info → kash_shell-0.3.15.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.13.dist-info → kash_shell-0.3.15.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.13.dist-info → kash_shell-0.3.15.dist-info}/licenses/LICENSE +0 -0
kash/actions/core/markdownify.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from kash.config.logger import get_logger
|
|
2
2
|
from kash.exec import kash_action
|
|
3
3
|
from kash.exec.preconditions import has_html_body, is_url_resource
|
|
4
|
+
from kash.exec.runtime_settings import current_runtime_settings
|
|
4
5
|
from kash.model import Format, Item
|
|
5
|
-
from kash.model.
|
|
6
|
+
from kash.model.items_model import ItemType
|
|
6
7
|
from kash.utils.text_handling.markdownify_utils import markdownify_custom
|
|
7
8
|
from kash.web_content.file_cache_utils import get_url_html
|
|
8
9
|
from kash.web_content.web_extract_readabilipy import extract_text_readabilipy
|
|
@@ -12,20 +13,22 @@ log = get_logger(__name__)
|
|
|
12
13
|
|
|
13
14
|
@kash_action(
|
|
14
15
|
precondition=is_url_resource | has_html_body,
|
|
15
|
-
params=common_params("refetch"),
|
|
16
16
|
mcp_tool=True,
|
|
17
17
|
)
|
|
18
|
-
def markdownify(item: Item
|
|
18
|
+
def markdownify(item: Item) -> Item:
|
|
19
19
|
"""
|
|
20
20
|
Converts a URL or raw HTML item to Markdown, fetching with the content
|
|
21
21
|
cache if needed. Also uses readability to clean up the HTML.
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
+
refetch = current_runtime_settings().refetch
|
|
24
25
|
expiration_sec = 0 if refetch else None
|
|
25
26
|
url, html_content = get_url_html(item, expiration_sec=expiration_sec)
|
|
26
27
|
page_data = extract_text_readabilipy(url, html_content)
|
|
27
28
|
assert page_data.clean_html
|
|
28
29
|
markdown_content = markdownify_custom(page_data.clean_html)
|
|
29
30
|
|
|
30
|
-
output_item = item.derived_copy(
|
|
31
|
+
output_item = item.derived_copy(
|
|
32
|
+
type=ItemType.doc, format=Format.markdown, body=markdown_content
|
|
33
|
+
)
|
|
31
34
|
return output_item
|
kash/actions/core/readability.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from kash.config.logger import get_logger
|
|
2
2
|
from kash.exec import kash_action
|
|
3
3
|
from kash.exec.preconditions import has_html_body, is_url_resource
|
|
4
|
+
from kash.exec.runtime_settings import current_runtime_settings
|
|
4
5
|
from kash.model import Format, Item
|
|
5
|
-
from kash.model.params_model import common_params
|
|
6
6
|
from kash.web_content.file_cache_utils import get_url_html
|
|
7
7
|
from kash.web_content.web_extract_readabilipy import extract_text_readabilipy
|
|
8
8
|
|
|
@@ -11,14 +11,15 @@ log = get_logger(__name__)
|
|
|
11
11
|
|
|
12
12
|
@kash_action(
|
|
13
13
|
precondition=is_url_resource | has_html_body,
|
|
14
|
-
params=common_params("refetch"),
|
|
15
14
|
mcp_tool=True,
|
|
16
15
|
)
|
|
17
|
-
def readability(item: Item
|
|
16
|
+
def readability(item: Item) -> Item:
|
|
18
17
|
"""
|
|
19
18
|
Extracts clean HTML from a raw HTML item.
|
|
20
19
|
See `markdownify` to also convert to Markdown.
|
|
21
20
|
"""
|
|
21
|
+
|
|
22
|
+
refetch = current_runtime_settings().refetch
|
|
22
23
|
expiration_sec = 0 if refetch else None
|
|
23
24
|
locator, html_content = get_url_html(item, expiration_sec=expiration_sec)
|
|
24
25
|
page_data = extract_text_readabilipy(locator, html_content)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from kash.actions.core.tabbed_webpage_config import tabbed_webpage_config
|
|
2
2
|
from kash.actions.core.tabbed_webpage_generate import tabbed_webpage_generate
|
|
3
3
|
from kash.exec import kash_action
|
|
4
|
-
from kash.exec.preconditions import
|
|
4
|
+
from kash.exec.preconditions import has_fullpage_html_body, has_html_body, has_simple_text_body
|
|
5
5
|
from kash.exec_model.args_model import ONE_OR_MORE_ARGS
|
|
6
6
|
from kash.model import ActionInput, ActionResult, Param
|
|
7
7
|
from kash.model.items_model import ItemType
|
|
@@ -11,7 +11,7 @@ from kash.web_gen.simple_webpage import simple_webpage_render
|
|
|
11
11
|
|
|
12
12
|
@kash_action(
|
|
13
13
|
expected_args=ONE_OR_MORE_ARGS,
|
|
14
|
-
precondition=(has_html_body | has_simple_text_body) & ~
|
|
14
|
+
precondition=(has_html_body | has_simple_text_body) & ~has_fullpage_html_body,
|
|
15
15
|
params=(Param("no_title", "Don't add a title to the page body.", type=bool),),
|
|
16
16
|
)
|
|
17
17
|
def render_as_html(input: ActionInput, no_title: bool = False) -> ActionResult:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from kash.actions.core.render_as_html import render_as_html
|
|
2
2
|
from kash.commands.base.show_command import show
|
|
3
3
|
from kash.exec import kash_action
|
|
4
|
-
from kash.exec.preconditions import
|
|
4
|
+
from kash.exec.preconditions import has_fullpage_html_body, has_html_body, has_simple_text_body
|
|
5
5
|
from kash.exec_model.args_model import ONE_OR_MORE_ARGS
|
|
6
6
|
from kash.exec_model.commands_model import Command
|
|
7
7
|
from kash.exec_model.shell_model import ShellResult
|
|
@@ -10,7 +10,7 @@ from kash.model import ActionInput, ActionResult
|
|
|
10
10
|
|
|
11
11
|
@kash_action(
|
|
12
12
|
expected_args=ONE_OR_MORE_ARGS,
|
|
13
|
-
precondition=(has_html_body | has_simple_text_body) & ~
|
|
13
|
+
precondition=(has_html_body | has_simple_text_body) & ~has_fullpage_html_body,
|
|
14
14
|
)
|
|
15
15
|
def show_webpage(input: ActionInput) -> ActionResult:
|
|
16
16
|
"""
|
|
@@ -7,6 +7,7 @@ from strif import copyfile_atomic
|
|
|
7
7
|
|
|
8
8
|
from kash.config.logger import get_logger
|
|
9
9
|
from kash.exec import kash_command
|
|
10
|
+
from kash.exec.resolve_args import assemble_path_args
|
|
10
11
|
from kash.exec_model.shell_model import ShellResult
|
|
11
12
|
from kash.model.paths_model import StorePath
|
|
12
13
|
from kash.shell.ui.shell_results import shell_print_selection_history
|
|
@@ -30,6 +31,7 @@ def select(
|
|
|
30
31
|
clear_all: bool = False,
|
|
31
32
|
clear_future: bool = False,
|
|
32
33
|
refresh: bool = False,
|
|
34
|
+
no_check: bool = False,
|
|
33
35
|
) -> ShellResult:
|
|
34
36
|
"""
|
|
35
37
|
Set or show the current selection.
|
|
@@ -51,6 +53,7 @@ def select(
|
|
|
51
53
|
:param clear_all: Clear the full selection history.
|
|
52
54
|
:param clear_future: Clear all selections from history after the current one.
|
|
53
55
|
:param refresh: Refresh the current selection to drop any paths that no longer exist.
|
|
56
|
+
:param no_check: Do not check if the paths exist.
|
|
54
57
|
"""
|
|
55
58
|
ws = current_ws()
|
|
56
59
|
|
|
@@ -68,6 +71,10 @@ def select(
|
|
|
68
71
|
raise InvalidInput("Cannot combine multiple flags")
|
|
69
72
|
if paths and any(exclusive_flags):
|
|
70
73
|
raise InvalidInput("Cannot combine paths with other flags")
|
|
74
|
+
if not no_check:
|
|
75
|
+
for path in paths:
|
|
76
|
+
if not Path(ws.base_dir / path).exists():
|
|
77
|
+
raise InvalidInput(f"Path does not exist: {fmt_loc(path)}")
|
|
71
78
|
|
|
72
79
|
if paths:
|
|
73
80
|
store_paths = [StorePath(path) for path in paths]
|
|
@@ -203,3 +210,18 @@ def save(parent: str | None = None, to: str | None = None, no_frontmatter: bool
|
|
|
203
210
|
for store_path in store_paths:
|
|
204
211
|
target_path = target_dir / basename(store_path)
|
|
205
212
|
copy_file(store_path, target_path)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@kash_command
|
|
216
|
+
def show_parent_dir(*paths: str) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Show the parent directory of the first item in the current selection.
|
|
219
|
+
"""
|
|
220
|
+
from kash.commands.base.show_command import show
|
|
221
|
+
|
|
222
|
+
input_paths = assemble_path_args(*paths)
|
|
223
|
+
if not input_paths:
|
|
224
|
+
raise InvalidInput("No paths provided")
|
|
225
|
+
|
|
226
|
+
input_path = current_ws().resolve_to_abs_path(input_paths[0])
|
|
227
|
+
show(input_path.parent)
|
|
@@ -59,6 +59,7 @@ from kash.utils.errors import InvalidInput
|
|
|
59
59
|
from kash.utils.file_formats.chat_format import tail_chat_history
|
|
60
60
|
from kash.utils.file_utils.dir_info import is_nonempty_dir
|
|
61
61
|
from kash.utils.file_utils.file_formats_model import Format
|
|
62
|
+
from kash.utils.text_handling.doc_normalization import can_normalize
|
|
62
63
|
from kash.web_content.file_cache_utils import cache_file
|
|
63
64
|
from kash.workspaces import (
|
|
64
65
|
current_ws,
|
|
@@ -189,56 +190,80 @@ def cache_content(*urls_or_paths: str, refetch: bool = False) -> None:
|
|
|
189
190
|
|
|
190
191
|
|
|
191
192
|
@kash_command
|
|
192
|
-
def download(*urls_or_paths: str, refetch: bool = False) -> ShellResult:
|
|
193
|
+
def download(*urls_or_paths: str, refetch: bool = False, no_format: bool = False) -> ShellResult:
|
|
193
194
|
"""
|
|
194
195
|
Download a URL or resource. Uses cached content if available, unless `refetch` is true.
|
|
195
196
|
Inputs can be URLs or paths to URL resources.
|
|
196
|
-
|
|
197
|
-
expiration_sec = 0 if refetch else None
|
|
197
|
+
Creates both resource and document versions for text content.
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
:param no_format: If true, do not also normalize Markdown content.
|
|
200
|
+
"""
|
|
200
201
|
ws = current_ws()
|
|
201
202
|
saved_paths = []
|
|
203
|
+
|
|
202
204
|
for url_or_path in urls_or_paths:
|
|
203
205
|
locator = resolve_locator_arg(url_or_path)
|
|
204
206
|
url: Url | None = None
|
|
207
|
+
|
|
208
|
+
# Get the URL from the locator
|
|
205
209
|
if not isinstance(locator, Path) and is_url(locator):
|
|
206
210
|
url = Url(locator)
|
|
207
|
-
|
|
211
|
+
elif isinstance(locator, StorePath):
|
|
208
212
|
url_item = ws.load(locator)
|
|
209
213
|
if is_url_resource(url_item):
|
|
210
214
|
url = url_item.url
|
|
215
|
+
|
|
211
216
|
if not url:
|
|
212
217
|
raise InvalidInput(f"Not a URL or URL resource: {fmt_loc(locator)}")
|
|
213
218
|
|
|
219
|
+
# Handle media URLs differently
|
|
214
220
|
if is_media_url(url):
|
|
215
221
|
log.message(
|
|
216
222
|
"URL is a media URL, so adding as a resource and will cache media: %s", fmt_loc(url)
|
|
217
223
|
)
|
|
218
|
-
store_path = ws.import_item(
|
|
224
|
+
store_path = ws.import_item(url, as_type=ItemType.resource, reimport=refetch)
|
|
225
|
+
saved_paths.append(store_path)
|
|
219
226
|
media_tools.cache_media(url)
|
|
220
227
|
else:
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
# Cache the content first
|
|
229
|
+
expiration_sec = 0 if refetch else None
|
|
223
230
|
cache_result = cache_file(url, expiration_sec=expiration_sec)
|
|
224
|
-
|
|
231
|
+
original_filename = Path(parse_http_url(url).path).name
|
|
225
232
|
mime_type = cache_result.content.headers and cache_result.content.headers.mime_type
|
|
226
|
-
|
|
233
|
+
|
|
234
|
+
# Create a resource item
|
|
235
|
+
resource_item = Item.from_external_path(
|
|
227
236
|
cache_result.content.path,
|
|
228
237
|
ItemType.resource,
|
|
238
|
+
url=url,
|
|
229
239
|
mime_type=mime_type,
|
|
230
240
|
original_filename=original_filename,
|
|
231
241
|
)
|
|
232
|
-
|
|
242
|
+
# For initial content, do not format or add frontmatter.
|
|
243
|
+
store_path = ws.save(resource_item, no_frontmatter=True, no_format=True)
|
|
233
244
|
saved_paths.append(store_path)
|
|
245
|
+
select(store_path)
|
|
246
|
+
|
|
247
|
+
# Also create a doc version for text content if we want to normalize formatting.
|
|
248
|
+
if resource_item.format and can_normalize(resource_item.format) and not no_format:
|
|
249
|
+
doc_item = Item.from_external_path(
|
|
250
|
+
cache_result.content.path,
|
|
251
|
+
ItemType.doc,
|
|
252
|
+
url=url,
|
|
253
|
+
mime_type=mime_type,
|
|
254
|
+
original_filename=original_filename,
|
|
255
|
+
)
|
|
256
|
+
# Now use default formatting and frontmatter.
|
|
257
|
+
doc_store_path = ws.save(doc_item)
|
|
258
|
+
saved_paths.append(doc_store_path)
|
|
259
|
+
select(doc_store_path)
|
|
234
260
|
|
|
235
261
|
print_status(
|
|
236
262
|
"Downloaded %s %s:\n%s",
|
|
237
|
-
len(
|
|
238
|
-
plural("item", len(
|
|
263
|
+
len(saved_paths),
|
|
264
|
+
plural("item", len(saved_paths)),
|
|
239
265
|
fmt_lines(saved_paths),
|
|
240
266
|
)
|
|
241
|
-
select(*saved_paths)
|
|
242
267
|
|
|
243
268
|
return ShellResult(show_selection=True)
|
|
244
269
|
|
|
@@ -483,7 +508,7 @@ def import_item(
|
|
|
483
508
|
|
|
484
509
|
|
|
485
510
|
@kash_command
|
|
486
|
-
def
|
|
511
|
+
def save_clipboard(
|
|
487
512
|
title: str | None = "pasted_text",
|
|
488
513
|
type: ItemType = ItemType.resource,
|
|
489
514
|
format: Format = Format.plaintext,
|
|
@@ -518,8 +543,6 @@ def fetch_metadata(*files_or_urls: str, refetch: bool = False) -> ShellResult:
|
|
|
518
543
|
|
|
519
544
|
Skips items that already have a title and description, unless `refetch` is true.
|
|
520
545
|
Skips (with a warning) items that are not URL resources.
|
|
521
|
-
|
|
522
|
-
:param use_cache: If true, also save page in content cache.
|
|
523
546
|
"""
|
|
524
547
|
if not files_or_urls:
|
|
525
548
|
locators = assemble_store_path_args()
|
|
@@ -529,12 +552,12 @@ def fetch_metadata(*files_or_urls: str, refetch: bool = False) -> ShellResult:
|
|
|
529
552
|
store_paths = []
|
|
530
553
|
for locator in locators:
|
|
531
554
|
try:
|
|
532
|
-
if isinstance(locator, Path):
|
|
533
|
-
raise InvalidInput()
|
|
534
555
|
fetched_item = fetch_url_metadata(locator, refetch=refetch)
|
|
535
556
|
store_paths.append(fetched_item.store_path)
|
|
536
|
-
except InvalidInput:
|
|
537
|
-
log.warning(
|
|
557
|
+
except InvalidInput as e:
|
|
558
|
+
log.warning(
|
|
559
|
+
"Not a URL or URL resource, will not fetch metadata: %s: %s", fmt_loc(locator), e
|
|
560
|
+
)
|
|
538
561
|
|
|
539
562
|
if store_paths:
|
|
540
563
|
select(*store_paths)
|
|
@@ -716,7 +739,7 @@ def reset_ignore_file(append: bool = False) -> None:
|
|
|
716
739
|
ignore_path = ws.base_dir / ws.dirs.ignore_file
|
|
717
740
|
write_ignore(ignore_path, append=append)
|
|
718
741
|
|
|
719
|
-
log.message("
|
|
742
|
+
log.message("Rewritten kash ignore file: %s", fmt_loc(ignore_path))
|
|
720
743
|
|
|
721
744
|
|
|
722
745
|
@kash_command
|
kash/exec/action_exec.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import time
|
|
2
2
|
from dataclasses import replace
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
|
|
4
|
-
from prettyfmt import fmt_lines, plural
|
|
5
|
+
from prettyfmt import fmt_lines, fmt_path, plural
|
|
5
6
|
|
|
6
7
|
from kash.config.logger import get_logger
|
|
7
8
|
from kash.config.text_styles import (
|
|
@@ -32,7 +33,6 @@ from kash.utils.common.task_stack import task_stack
|
|
|
32
33
|
from kash.utils.common.type_utils import not_none
|
|
33
34
|
from kash.utils.errors import ContentError, InvalidOutput, get_nonfatal_exceptions
|
|
34
35
|
from kash.workspaces import Selection, current_ws
|
|
35
|
-
from kash.workspaces.workspace_importing import import_and_load
|
|
36
36
|
|
|
37
37
|
log = get_logger(__name__)
|
|
38
38
|
|
|
@@ -49,7 +49,7 @@ def prepare_action_input(*input_args: CommandArg, refetch: bool = False) -> Acti
|
|
|
49
49
|
|
|
50
50
|
# Ensure input items are already saved in the workspace and load the corresponding items.
|
|
51
51
|
# This also imports any URLs.
|
|
52
|
-
input_items = [import_and_load(
|
|
52
|
+
input_items = [ws.import_and_load(arg) for arg in input_args]
|
|
53
53
|
|
|
54
54
|
# URLs should have metadata like a title and be valid, so we fetch them.
|
|
55
55
|
if input_items:
|
|
@@ -288,6 +288,14 @@ def save_action_result(
|
|
|
288
288
|
Save the result of an action to the workspace. Handles skipping duplicates and
|
|
289
289
|
archiving old inputs, if appropriate, based on hints in the ActionResult.
|
|
290
290
|
"""
|
|
291
|
+
# If an action returned an external path, we should confirm it exists or it is probably
|
|
292
|
+
# a bug in the action.
|
|
293
|
+
for item in result.items:
|
|
294
|
+
if item.external_path and not Path(item.external_path).exists():
|
|
295
|
+
raise InvalidOutput(
|
|
296
|
+
f"External path returned by action does not exist: {fmt_path(item.external_path)}"
|
|
297
|
+
)
|
|
298
|
+
|
|
291
299
|
input_items = action_input.items
|
|
292
300
|
|
|
293
301
|
skipped_paths = []
|
|
@@ -378,12 +386,16 @@ def run_action_with_caching(
|
|
|
378
386
|
# Run it!
|
|
379
387
|
result = run_action_operation(context, action_input, operation)
|
|
380
388
|
result_store_paths, archived_store_paths = save_action_result(
|
|
381
|
-
ws,
|
|
389
|
+
ws,
|
|
390
|
+
result,
|
|
391
|
+
action_input,
|
|
392
|
+
as_tmp=settings.tmp_output,
|
|
393
|
+
no_format=settings.no_format,
|
|
382
394
|
)
|
|
383
395
|
|
|
384
396
|
PrintHooks.before_done_message()
|
|
385
397
|
log.message(
|
|
386
|
-
"%s
|
|
398
|
+
"%s Action: `%s` completed with %s %s",
|
|
387
399
|
EMOJI_SUCCESS,
|
|
388
400
|
action.name,
|
|
389
401
|
len(result.items),
|
|
@@ -449,13 +461,19 @@ def run_action_with_shell_context(
|
|
|
449
461
|
|
|
450
462
|
# As a special case for convenience, if the action expects no args, ignore any pre-selected inputs.
|
|
451
463
|
if action.expected_args == NO_ARGS and from_selection:
|
|
452
|
-
log.message(
|
|
464
|
+
log.message(
|
|
465
|
+
"Not using current selection since action `%s` expects no args.",
|
|
466
|
+
action_name,
|
|
467
|
+
)
|
|
453
468
|
args.clear()
|
|
454
469
|
|
|
455
470
|
if args:
|
|
456
471
|
source_str = "provided args" if provided_args else "selection"
|
|
457
472
|
log.message(
|
|
458
|
-
"Using %s as inputs to action `%s`:\n%s",
|
|
473
|
+
"Using %s as inputs to action `%s`:\n%s",
|
|
474
|
+
source_str,
|
|
475
|
+
action_name,
|
|
476
|
+
fmt_lines(args),
|
|
459
477
|
)
|
|
460
478
|
|
|
461
479
|
# Get items for each input arg.
|
|
@@ -16,11 +16,13 @@ def kash_precondition(func: Callable[[Item], bool]) -> Precondition:
|
|
|
16
16
|
"""
|
|
17
17
|
Decorator to register a function as a Precondition.
|
|
18
18
|
The function should return a bool and/or raise `PreconditionFailure`.
|
|
19
|
+
Returns an actual Precondition object, not the function, so that it's possible to
|
|
20
|
+
do precondition algebra, e.g. `is_file & has_body`.
|
|
19
21
|
|
|
20
22
|
Example:
|
|
21
|
-
@
|
|
22
|
-
def
|
|
23
|
-
return item.
|
|
23
|
+
@kash_precondition
|
|
24
|
+
def has_body(item: Item) -> bool:
|
|
25
|
+
return item.has_body()
|
|
24
26
|
"""
|
|
25
27
|
precondition = Precondition(func)
|
|
26
28
|
|
kash/exec/preconditions.py
CHANGED
|
@@ -7,7 +7,7 @@ from chopdiff.html import has_timestamp
|
|
|
7
7
|
|
|
8
8
|
from kash.exec.precondition_registry import kash_precondition
|
|
9
9
|
from kash.model.items_model import Item, ItemType
|
|
10
|
-
from kash.utils.file_utils.file_formats import
|
|
10
|
+
from kash.utils.file_utils.file_formats import is_fullpage_html
|
|
11
11
|
from kash.utils.file_utils.file_formats_model import Format
|
|
12
12
|
from kash.utils.text_handling.markdown_utils import extract_bullet_points
|
|
13
13
|
|
|
@@ -22,9 +22,14 @@ def is_doc_resource(item: Item) -> bool:
|
|
|
22
22
|
return bool(is_resource(item) and item.format and item.format.is_doc)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@kash_precondition
|
|
26
|
+
def is_markdown_resource(item: Item) -> bool:
|
|
27
|
+
return bool(is_resource(item) and item.format and item.format.is_markdown)
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
@kash_precondition
|
|
26
31
|
def is_html_resource(item: Item) -> bool:
|
|
27
|
-
return bool(is_resource(item) and item.format and item.format
|
|
32
|
+
return bool(is_resource(item) and item.format and item.format.is_html)
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
@kash_precondition
|
|
@@ -100,8 +105,23 @@ def has_html_body(item: Item) -> bool:
|
|
|
100
105
|
|
|
101
106
|
|
|
102
107
|
@kash_precondition
|
|
103
|
-
def
|
|
104
|
-
return bool(
|
|
108
|
+
def has_html_compatible_body(item: Item) -> bool:
|
|
109
|
+
return bool(has_body(item) and item.format and item.format.is_html_compatible)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@kash_precondition
|
|
113
|
+
def has_markdown_body(item: Item) -> bool:
|
|
114
|
+
return bool(has_body(item) and item.format and item.format.is_markdown)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@kash_precondition
|
|
118
|
+
def has_markdown_with_html_body(item: Item) -> bool:
|
|
119
|
+
return bool(has_body(item) and item.format and item.format.is_markdown_with_html)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@kash_precondition
|
|
123
|
+
def has_fullpage_html_body(item: Item) -> bool:
|
|
124
|
+
return bool(has_html_body(item) and item.body and is_fullpage_html(item.body))
|
|
105
125
|
|
|
106
126
|
|
|
107
127
|
@kash_precondition
|
|
@@ -114,16 +134,21 @@ def is_markdown(item: Item) -> bool:
|
|
|
114
134
|
return bool(has_body(item) and item.format and item.format.is_markdown)
|
|
115
135
|
|
|
116
136
|
|
|
137
|
+
@kash_precondition
|
|
138
|
+
def is_markdown_with_html(item: Item) -> bool:
|
|
139
|
+
return bool(has_body(item) and item.format and item.format.is_markdown_with_html)
|
|
140
|
+
|
|
141
|
+
|
|
117
142
|
@kash_precondition
|
|
118
143
|
def is_markdown_template(item: Item) -> bool:
|
|
119
|
-
return
|
|
144
|
+
return has_markdown_body(item) and contains_curly_vars(item)
|
|
120
145
|
|
|
121
146
|
|
|
122
147
|
@kash_precondition
|
|
123
148
|
def is_markdown_list(item: Item) -> bool:
|
|
124
149
|
try:
|
|
125
150
|
return (
|
|
126
|
-
|
|
151
|
+
has_markdown_body(item)
|
|
127
152
|
and item.body is not None
|
|
128
153
|
and len(extract_bullet_points(item.body)) >= 2
|
|
129
154
|
)
|
kash/exec/resolve_args.py
CHANGED
|
@@ -42,7 +42,7 @@ def resolve_path_arg(path_str: UnresolvedPath) -> Path | StorePath:
|
|
|
42
42
|
return path
|
|
43
43
|
else:
|
|
44
44
|
try:
|
|
45
|
-
store_path = current_ws().
|
|
45
|
+
store_path = current_ws().resolve_to_store_path(path)
|
|
46
46
|
if store_path:
|
|
47
47
|
return store_path
|
|
48
48
|
else:
|
|
@@ -110,7 +110,7 @@ def resolvable_paths(paths: Sequence[StorePath | Path]) -> list[StorePath]:
|
|
|
110
110
|
current workspace.
|
|
111
111
|
"""
|
|
112
112
|
ws = current_ws()
|
|
113
|
-
resolvable = list(filter(None, (ws.
|
|
113
|
+
resolvable = list(filter(None, (ws.resolve_to_store_path(p) for p in paths)))
|
|
114
114
|
return resolvable
|
|
115
115
|
|
|
116
116
|
|
kash/file_storage/file_store.py
CHANGED
|
@@ -22,7 +22,7 @@ from kash.model.paths_model import StorePath
|
|
|
22
22
|
from kash.shell.output.shell_output import PrintHooks
|
|
23
23
|
from kash.utils.common.format_utils import fmt_loc
|
|
24
24
|
from kash.utils.common.uniquifier import Uniquifier
|
|
25
|
-
from kash.utils.common.url import Locator, Url, is_url
|
|
25
|
+
from kash.utils.common.url import Locator, UnresolvedLocator, Url, is_url
|
|
26
26
|
from kash.utils.errors import FileExists, FileNotFound, InvalidFilename, SkippableError
|
|
27
27
|
from kash.utils.file_utils.file_formats_model import Format
|
|
28
28
|
from kash.utils.file_utils.file_walk import walk_by_dir
|
|
@@ -182,7 +182,7 @@ class FileStore(Workspace):
|
|
|
182
182
|
except (FileNotFoundError, InvalidFilename):
|
|
183
183
|
pass
|
|
184
184
|
|
|
185
|
-
def
|
|
185
|
+
def resolve_to_store_path(self, path: Path | StorePath) -> StorePath | None:
|
|
186
186
|
"""
|
|
187
187
|
Return a StorePath if the given path is within the store, otherwise None.
|
|
188
188
|
If it is already a StorePath, return it unchanged.
|
|
@@ -195,6 +195,21 @@ class FileStore(Workspace):
|
|
|
195
195
|
else:
|
|
196
196
|
return None
|
|
197
197
|
|
|
198
|
+
def resolve_to_abs_path(self, path: Path | StorePath) -> Path:
|
|
199
|
+
"""
|
|
200
|
+
Return an absolute path, resolving any store paths to within the store
|
|
201
|
+
and resolving other paths like regular `Path.resolve()`.
|
|
202
|
+
"""
|
|
203
|
+
store_path = self.resolve_to_store_path(path)
|
|
204
|
+
if store_path:
|
|
205
|
+
return self.base_dir / store_path
|
|
206
|
+
elif path.is_absolute():
|
|
207
|
+
return path
|
|
208
|
+
else:
|
|
209
|
+
# Unspecified relative paths resolved to cwd.
|
|
210
|
+
# TODO: Consider if such paths might be store paths.
|
|
211
|
+
return path.resolve()
|
|
212
|
+
|
|
198
213
|
def exists(self, store_path: StorePath) -> bool:
|
|
199
214
|
"""
|
|
200
215
|
Check given store path refers to an existing file.
|
|
@@ -290,7 +305,7 @@ class FileStore(Workspace):
|
|
|
290
305
|
elif item_id in self.id_map and self.exists(self.id_map[item_id]):
|
|
291
306
|
# If this item has an identity and we've saved under that id before, use the same store path.
|
|
292
307
|
store_path = self.id_map[item_id]
|
|
293
|
-
log.
|
|
308
|
+
log.info(
|
|
294
309
|
"Found existing item with same id:\n%s",
|
|
295
310
|
fmt_lines([fmt_loc(store_path), item_id]),
|
|
296
311
|
)
|
|
@@ -334,6 +349,7 @@ class FileStore(Workspace):
|
|
|
334
349
|
skip_dup_names: bool = False,
|
|
335
350
|
as_tmp: bool = False,
|
|
336
351
|
no_format: bool = False,
|
|
352
|
+
no_frontmatter: bool = False,
|
|
337
353
|
) -> StorePath:
|
|
338
354
|
"""
|
|
339
355
|
Save the item. Uses the `store_path` if it's already set or generates a new one.
|
|
@@ -342,6 +358,8 @@ class FileStore(Workspace):
|
|
|
342
358
|
Unless `no_format` is true, also normalizes body text formatting (for Markdown)
|
|
343
359
|
and updates the item's body to match.
|
|
344
360
|
|
|
361
|
+
If `no_frontmatter` is true, will not add frontmatter metadata to the item.
|
|
362
|
+
|
|
345
363
|
If `overwrite` is true, will overwrite a file that has the same path.
|
|
346
364
|
|
|
347
365
|
If `as_tmp` is true, will save the item to a temporary file.
|
|
@@ -390,9 +408,14 @@ class FileStore(Workspace):
|
|
|
390
408
|
|
|
391
409
|
# Now save the new item.
|
|
392
410
|
try:
|
|
393
|
-
|
|
411
|
+
supports_frontmatter = item.format and item.format.supports_frontmatter
|
|
412
|
+
# For binary or unknown formats or if we're not adding frontmatter, copy the file exactly.
|
|
413
|
+
if item.external_path and (no_frontmatter or not supports_frontmatter):
|
|
394
414
|
copyfile_atomic(item.external_path, full_path, make_parents=True)
|
|
395
415
|
else:
|
|
416
|
+
# Save as a text item with frontmatter.
|
|
417
|
+
if item.external_path:
|
|
418
|
+
item.body = Path(item.external_path).read_text()
|
|
396
419
|
if overwrite and full_path.exists():
|
|
397
420
|
log.info(
|
|
398
421
|
"Overwrite is enabled and a previous file exists so will archive it: %s",
|
|
@@ -448,7 +471,7 @@ class FileStore(Workspace):
|
|
|
448
471
|
|
|
449
472
|
def import_item(
|
|
450
473
|
self,
|
|
451
|
-
locator:
|
|
474
|
+
locator: UnresolvedLocator,
|
|
452
475
|
*,
|
|
453
476
|
as_type: ItemType | None = None,
|
|
454
477
|
reimport: bool = False,
|
|
@@ -462,7 +485,10 @@ class FileStore(Workspace):
|
|
|
462
485
|
"""
|
|
463
486
|
from kash.web_content.canon_url import canonicalize_url
|
|
464
487
|
|
|
465
|
-
if
|
|
488
|
+
if isinstance(locator, StorePath) and not reimport:
|
|
489
|
+
log.info("Store path already imported: %s", fmt_loc(locator))
|
|
490
|
+
return locator
|
|
491
|
+
elif is_url(locator):
|
|
466
492
|
# Import a URL as a resource.
|
|
467
493
|
orig_url = Url(str(locator))
|
|
468
494
|
url = canonicalize_url(orig_url)
|
|
@@ -480,9 +506,6 @@ class FileStore(Workspace):
|
|
|
480
506
|
else:
|
|
481
507
|
store_path = self.save(item)
|
|
482
508
|
return store_path
|
|
483
|
-
elif isinstance(locator, StorePath) and not reimport:
|
|
484
|
-
log.info("Store path already imported: %s", fmt_loc(locator))
|
|
485
|
-
return locator
|
|
486
509
|
else:
|
|
487
510
|
# We have a path, possibly outside of or inside of the store.
|
|
488
511
|
path = Path(locator).resolve()
|
|
@@ -553,6 +576,13 @@ class FileStore(Workspace):
|
|
|
553
576
|
self.import_item(locator, as_type=as_type, reimport=reimport) for locator in locators
|
|
554
577
|
]
|
|
555
578
|
|
|
579
|
+
def import_and_load(self, locator: UnresolvedLocator) -> Item:
|
|
580
|
+
"""
|
|
581
|
+
Import a locator and return the item.
|
|
582
|
+
"""
|
|
583
|
+
store_path = self.import_item(locator)
|
|
584
|
+
return self.load(store_path)
|
|
585
|
+
|
|
556
586
|
def _filter_selection_paths(self):
|
|
557
587
|
"""
|
|
558
588
|
Filter out any paths that don't exist from all selections.
|
|
@@ -695,14 +725,20 @@ class FileStore(Workspace):
|
|
|
695
725
|
dirs_ignored,
|
|
696
726
|
)
|
|
697
727
|
|
|
698
|
-
def normalize(
|
|
728
|
+
def normalize(
|
|
729
|
+
self,
|
|
730
|
+
store_path: StorePath,
|
|
731
|
+
*,
|
|
732
|
+
no_format: bool = False,
|
|
733
|
+
no_frontmatter: bool = False,
|
|
734
|
+
) -> StorePath:
|
|
699
735
|
"""
|
|
700
736
|
Normalize an item or all items in a folder to make sure contents are in current
|
|
701
|
-
format.
|
|
737
|
+
format. This is the same as loading and saving the item.
|
|
702
738
|
"""
|
|
703
739
|
log.info("Normalizing item: %s", fmt_path(store_path))
|
|
704
740
|
|
|
705
741
|
item = self.load(store_path)
|
|
706
|
-
new_store_path = self.save(item)
|
|
742
|
+
new_store_path = self.save(item, no_format=no_format, no_frontmatter=no_frontmatter)
|
|
707
743
|
|
|
708
744
|
return new_store_path
|