kash-shell 0.3.10__py3-none-any.whl → 0.3.11__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.
Files changed (40) hide show
  1. kash/actions/core/format_markdown_template.py +2 -5
  2. kash/actions/core/markdownify.py +2 -4
  3. kash/actions/core/readability.py +2 -4
  4. kash/actions/core/render_as_html.py +30 -11
  5. kash/actions/core/show_webpage.py +6 -11
  6. kash/actions/core/strip_html.py +2 -6
  7. kash/actions/core/{webpage_config.py → tabbed_webpage_config.py} +5 -3
  8. kash/actions/core/{webpage_generate.py → tabbed_webpage_generate.py} +5 -4
  9. kash/commands/base/files_command.py +28 -10
  10. kash/commands/workspace/workspace_commands.py +1 -2
  11. kash/config/colors.py +2 -2
  12. kash/exec/action_decorators.py +6 -6
  13. kash/exec/llm_transforms.py +6 -3
  14. kash/exec/preconditions.py +6 -0
  15. kash/exec/resolve_args.py +4 -0
  16. kash/file_storage/file_store.py +20 -18
  17. kash/help/function_param_info.py +1 -1
  18. kash/local_server/local_server_routes.py +1 -7
  19. kash/model/items_model.py +74 -28
  20. kash/shell/utils/shell_function_wrapper.py +15 -15
  21. kash/text_handling/doc_normalization.py +1 -1
  22. kash/text_handling/markdown_render.py +1 -0
  23. kash/text_handling/markdown_utils.py +22 -0
  24. kash/utils/common/function_inspect.py +360 -110
  25. kash/utils/file_utils/file_ext.py +4 -0
  26. kash/utils/file_utils/file_formats_model.py +17 -1
  27. kash/web_gen/__init__.py +0 -4
  28. kash/web_gen/simple_webpage.py +52 -0
  29. kash/web_gen/tabbed_webpage.py +23 -16
  30. kash/web_gen/template_render.py +37 -2
  31. kash/web_gen/templates/base_styles.css.jinja +76 -56
  32. kash/web_gen/templates/base_webpage.html.jinja +85 -67
  33. kash/web_gen/templates/item_view.html.jinja +47 -37
  34. kash/web_gen/templates/simple_webpage.html.jinja +24 -0
  35. kash/web_gen/templates/tabbed_webpage.html.jinja +42 -32
  36. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/METADATA +5 -5
  37. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/RECORD +40 -38
  38. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/WHEEL +0 -0
  39. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/entry_points.txt +0 -0
  40. {kash_shell-0.3.10.dist-info → kash_shell-0.3.11.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
  from kash.config.logger import get_logger
5
5
  from kash.exec import kash_action
6
6
  from kash.exec.preconditions import is_markdown
7
- from kash.model import ONE_OR_MORE_ARGS, ActionInput, ActionResult, ItemType, Param
7
+ from kash.model import ONE_OR_MORE_ARGS, ActionInput, ActionResult, Param
8
8
  from kash.utils.common.type_utils import not_none
9
9
  from kash.utils.errors import InvalidInput
10
10
 
@@ -84,9 +84,6 @@ def format_markdown_template(
84
84
  # Format the body using the mapped items.
85
85
  body = template.format(**item_map)
86
86
 
87
- result_item = items[0].derived_copy(
88
- type=ItemType.doc,
89
- body=body,
90
- )
87
+ result_item = items[0].derived_copy(body=body)
91
88
 
92
89
  return ActionResult([result_item])
@@ -1,7 +1,7 @@
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_item
4
- from kash.model import Format, Item, ItemType
4
+ from kash.model import Format, Item
5
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
@@ -26,7 +26,5 @@ def markdownify(item: Item, refetch: bool = False) -> Item:
26
26
  page_data = extract_text_readabilipy(url, html_content)
27
27
  markdown_content = markdownify_convert(page_data.clean_html)
28
28
 
29
- output_item = item.derived_copy(
30
- type=ItemType.doc, format=Format.markdown, body=markdown_content
31
- )
29
+ output_item = item.derived_copy(format=Format.markdown, body=markdown_content)
32
30
  return output_item
@@ -1,7 +1,7 @@
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_item
4
- from kash.model import Format, Item, ItemType
4
+ from kash.model import Format, Item
5
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
@@ -23,8 +23,6 @@ def readability(item: Item, refetch: bool = False) -> Item:
23
23
  url, html_content = get_url_html(item, expiration_sec=expiration_sec)
24
24
  page_data = extract_text_readabilipy(url, html_content)
25
25
 
26
- output_item = item.derived_copy(
27
- type=ItemType.doc, format=Format.html, body=page_data.clean_html
28
- )
26
+ output_item = item.derived_copy(format=Format.html, body=page_data.clean_html)
29
27
 
30
28
  return output_item
@@ -1,18 +1,37 @@
1
- from kash.actions.core.webpage_config import webpage_config
2
- from kash.actions.core.webpage_generate import webpage_generate
1
+ from kash.actions.core.tabbed_webpage_config import tabbed_webpage_config
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 has_text_body, is_html
5
- from kash.model import ActionInput, ActionResult
4
+ from kash.exec.preconditions import has_full_html_page_body, has_text_body, is_html
5
+ from kash.exec_model.args_model import ONE_OR_MORE_ARGS
6
+ from kash.model import ActionInput, ActionResult, Param
7
+ from kash.model.items_model import ItemType
8
+ from kash.utils.file_utils.file_formats_model import Format
9
+ from kash.web_gen.simple_webpage import simple_webpage_render
6
10
 
7
11
 
8
12
  @kash_action(
9
- precondition=is_html | has_text_body,
13
+ expected_args=ONE_OR_MORE_ARGS,
14
+ precondition=(is_html | has_text_body) & ~has_full_html_page_body,
15
+ params=(Param("add_title", "Add a title to the page body.", type=bool),),
10
16
  )
11
- def render_as_html(input: ActionInput) -> ActionResult:
17
+ def render_as_html(input: ActionInput, add_title: bool = False) -> ActionResult:
12
18
  """
13
- Convert text, Markdown, or HTML to pretty, formatted HTML using the kash default
14
- page template.
19
+ Convert text, Markdown, or HTML to pretty, formatted HTML using a clean
20
+ and simple page template. Supports GFM-flavored Markdown tables and footnotes.
21
+
22
+ If it's a single input, the output is a simple HTML page.
23
+ If it's multiple inputs, the output is a tabbed HTML page.
24
+
25
+ This adds a header, footer, etc. so should be used on a plain document or HTML basic
26
+ page, not a full HTML page with header and body already present.
15
27
  """
16
- config_result = webpage_config(input)
17
- result = webpage_generate(ActionInput(items=config_result.items))
18
- return result
28
+ if len(input.items) == 1:
29
+ input_item = input.items[0]
30
+ html_body = simple_webpage_render(input_item, add_title_h1=add_title)
31
+ result_item = input_item.derived_copy(
32
+ type=ItemType.export, format=Format.html, body=html_body
33
+ )
34
+ return ActionResult([result_item])
35
+ else:
36
+ config_result = tabbed_webpage_config(input)
37
+ return tabbed_webpage_generate(ActionInput(items=config_result.items), add_title=add_title)
@@ -1,27 +1,22 @@
1
- from kash.actions.core.webpage_config import webpage_config
2
- from kash.actions.core.webpage_generate import webpage_generate
1
+ from kash.actions.core.render_as_html import render_as_html
3
2
  from kash.commands.base.show_command import show
4
- from kash.config.logger import get_logger
5
3
  from kash.exec import kash_action
6
- from kash.exec.preconditions import has_text_body, is_html
4
+ from kash.exec.preconditions import has_full_html_page_body, has_text_body, is_html
5
+ from kash.exec_model.args_model import ONE_OR_MORE_ARGS
7
6
  from kash.exec_model.commands_model import Command
8
7
  from kash.exec_model.shell_model import ShellResult
9
8
  from kash.model import ActionInput, ActionResult
10
9
 
11
- log = get_logger(__name__)
12
-
13
10
 
14
11
  @kash_action(
15
- precondition=is_html | has_text_body,
12
+ expected_args=ONE_OR_MORE_ARGS,
13
+ precondition=(is_html | has_text_body) & ~has_full_html_page_body,
16
14
  )
17
15
  def show_webpage(input: ActionInput) -> ActionResult:
18
16
  """
19
17
  Show text, Markdown, or HTML as a nicely formatted webpage.
20
18
  """
21
- config_result = webpage_config(input)
22
-
23
- log.message("Configured web page: %s", config_result)
24
- result = webpage_generate(ActionInput(items=config_result.items))
19
+ result = render_as_html(input)
25
20
 
26
21
  # Automatically show the result.
27
22
  result.shell_result = ShellResult(display_command=Command.assemble(show))
@@ -1,7 +1,7 @@
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, has_text_body
4
- from kash.model import Format, Item, ItemType
4
+ from kash.model import Format, Item
5
5
  from kash.utils.common.format_utils import html_to_plaintext
6
6
  from kash.utils.errors import InvalidInput
7
7
 
@@ -19,10 +19,6 @@ def strip_html(item: Item) -> Item:
19
19
  raise InvalidInput("Item must have a body")
20
20
 
21
21
  clean_body = html_to_plaintext(item.body)
22
- output_item = item.derived_copy(
23
- type=ItemType.doc,
24
- format=Format.markdown,
25
- body=clean_body,
26
- )
22
+ output_item = item.derived_copy(format=Format.markdown, body=clean_body)
27
23
 
28
24
  return output_item
@@ -1,5 +1,6 @@
1
1
  from kash.config.logger import get_logger
2
2
  from kash.exec import kash_action
3
+ from kash.exec_model.args_model import ONE_OR_MORE_ARGS
3
4
  from kash.model import ActionInput, ActionResult, Param
4
5
  from kash.utils.errors import InvalidInput
5
6
  from kash.web_gen import tabbed_webpage
@@ -8,15 +9,16 @@ log = get_logger(__name__)
8
9
 
9
10
 
10
11
  @kash_action(
12
+ expected_args=ONE_OR_MORE_ARGS,
11
13
  params=(
12
14
  Param(
13
15
  name="clean_headings",
14
16
  type=bool,
15
17
  description="Use an LLM to clean up headings.",
16
18
  ),
17
- )
19
+ ),
18
20
  )
19
- def webpage_config(input: ActionInput, clean_headings: bool = False) -> ActionResult:
21
+ def tabbed_webpage_config(input: ActionInput, clean_headings: bool = False) -> ActionResult:
20
22
  """
21
23
  Set up a web page config with optional tabs for each page of content. Uses first item as the page title.
22
24
  """
@@ -24,6 +26,6 @@ def webpage_config(input: ActionInput, clean_headings: bool = False) -> ActionRe
24
26
  if not item.body:
25
27
  raise InvalidInput(f"Item must have a body: {item}")
26
28
 
27
- config_item = tabbed_webpage.webpage_config(input.items, clean_headings)
29
+ config_item = tabbed_webpage.tabbed_webpage_config(input.items, clean_headings)
28
30
 
29
31
  return ActionResult([config_item])
@@ -1,7 +1,7 @@
1
1
  from kash.config.logger import get_logger
2
2
  from kash.exec import kash_action
3
3
  from kash.exec.preconditions import is_config
4
- from kash.model import ONE_ARG, ActionInput, ActionResult, FileExt, Format, Item, ItemType
4
+ from kash.model import ONE_ARG, ActionInput, ActionResult, FileExt, Format, Item, ItemType, Param
5
5
  from kash.web_gen import tabbed_webpage
6
6
 
7
7
  log = get_logger(__name__)
@@ -10,13 +10,14 @@ log = get_logger(__name__)
10
10
  @kash_action(
11
11
  expected_args=ONE_ARG,
12
12
  precondition=is_config,
13
+ params=(Param("add_title", "Add a title to the page body.", type=bool),),
13
14
  )
14
- def webpage_generate(input: ActionInput) -> ActionResult:
15
+ def tabbed_webpage_generate(input: ActionInput, add_title: bool = False) -> ActionResult:
15
16
  """
16
- Generate a web page from a configured web page item.
17
+ Generate a tabbed web page from a config item for the tabbed template.
17
18
  """
18
19
  config_item = input.items[0]
19
- html = tabbed_webpage.webpage_generate(config_item)
20
+ html = tabbed_webpage.tabbed_webpage_generate(config_item, add_title_h1=add_title)
20
21
 
21
22
  webpage_item = Item(
22
23
  title=config_item.title,
@@ -75,6 +75,9 @@ def _print_listing_tallies(
75
75
  cprint("(use --no_max to remove cutoff)", style=STYLE_HINT)
76
76
 
77
77
 
78
+ DEFAULT_MAX_PG = 100
79
+
80
+
78
81
  @kash_command
79
82
  def files(
80
83
  *paths: str,
@@ -108,9 +111,11 @@ def files(
108
111
  and grouping.
109
112
 
110
113
  :param overview: Recurse a couple levels and show files, but not too many.
111
- Same as `--groupby=parent --depth=2 --max_per_group=10 --omit_dirs`.
114
+ Same as `--groupby=parent --depth=2 --max_per_group=100 --omit_dirs`
115
+ except also scales down `max_per_group` to 25 or 50 if there are many files.
112
116
  :param recent: Only shows the most recently modified files in each directory.
113
- Same as `--sort=modified --reverse --groupby=parent --max_per_group=10`.
117
+ Same as `--sort=modified --reverse --groupby=parent --max_per_group=100`
118
+ except also scales down `max_per_group` to 25 or 50 if there are many files.
114
119
  :param recursive: List all files recursively. Same as `--depth=-1`.
115
120
  :param flat: Show files in a flat list, rather than grouped by parent directory.
116
121
  Same as `--groupby=flat`.
@@ -179,16 +184,17 @@ def files(
179
184
  # Within workspaces, we show more files by default since they are always in
180
185
  # subdirectories.
181
186
  overview = True # Handled next.
187
+ cap_per_group = False
182
188
  if overview:
183
- max_per_group = 10 if max_per_group <= 0 else max_per_group
184
189
  groupby = GroupByOption.parent if groupby is None else groupby
185
190
  depth = 2 if depth is None else depth
191
+ cap_per_group = True
186
192
  omit_dirs = True
187
193
  if recent:
188
- max_per_group = 10 if max_per_group <= 0 else max_per_group
189
194
  groupby = GroupByOption.parent if groupby is None else groupby
190
195
  depth = 2 if depth is None else depth
191
196
  sort = SortOption.modified if sort is None else sort
197
+ cap_per_group = True
192
198
  reverse = True
193
199
  if flat:
194
200
  groupby = GroupByOption.flat
@@ -291,6 +297,18 @@ def files(
291
297
 
292
298
  return ShellResult(show_selection=True)
293
299
 
300
+ # Unless max_per_group is explicit, use heuristics to limit per group if
301
+ # there are lots of groups and lots of files per group.
302
+ # Default is max 100 per group but if we have 4 * 100 items, cut to 25.
303
+ # If we have 2 * 100 items, cut to 50.
304
+ final_max_pg = DEFAULT_MAX_PG if cap_per_group else max_per_group
305
+ max_pg_explicit = max_per_group > 0
306
+ if not max_pg_explicit:
307
+ group_lens = [len(group_df) for group_df in grouped]
308
+ for ratio in [2, 4]:
309
+ if sum(group_lens) > ratio * DEFAULT_MAX_PG:
310
+ final_max_pg = int(DEFAULT_MAX_PG / ratio)
311
+
294
312
  total_displayed = 0
295
313
  total_displayed_size = 0
296
314
  now = datetime.now(UTC)
@@ -312,8 +330,8 @@ def files(
312
330
  text_wrap=Wrap.NONE,
313
331
  )
314
332
 
315
- if max_per_group > 0:
316
- display_df = group_df.head(max_per_group)
333
+ if final_max_pg > 0:
334
+ display_df = group_df.head(final_max_pg)
317
335
  else:
318
336
  display_df = group_df
319
337
 
@@ -378,9 +396,9 @@ def files(
378
396
  total_displayed_size += row.size
379
397
 
380
398
  # Indicate if items are omitted.
381
- if groupby and max_per_group > 0 and len(group_df) > max_per_group:
399
+ if groupby and final_max_pg > 0 and len(group_df) > final_max_pg:
382
400
  cprint(
383
- f"{indent}… and {len(group_df) - max_per_group} more files",
401
+ f"{indent}… and {len(group_df) - final_max_pg} more files",
384
402
  style=COLOR_EXTRA,
385
403
  text_wrap=Wrap.NONE,
386
404
  )
@@ -388,9 +406,9 @@ def files(
388
406
  if group_name:
389
407
  PrintHooks.spacer()
390
408
 
391
- if not groupby and max_per_group > 0 and items_matching > max_per_group:
409
+ if not groupby and final_max_pg > 0 and items_matching > final_max_pg:
392
410
  cprint(
393
- f"{indent}… and {items_matching - max_per_group} more files",
411
+ f"{indent}… and {items_matching - final_max_pg} more files",
394
412
  style=COLOR_EXTRA,
395
413
  text_wrap=Wrap.NONE,
396
414
  )
@@ -285,7 +285,7 @@ def init_workspace(path: str | None = None) -> None:
285
285
  @kash_command
286
286
  def workspace(workspace_name: str | None = None) -> None:
287
287
  """
288
- If no args are given, change directory to the current workspace.
288
+ If no args are given, show current workspace info.
289
289
  If a workspace name is given, change to that workspace, creating it if it doesn't exist.
290
290
  """
291
291
  if workspace_name:
@@ -302,7 +302,6 @@ def workspace(workspace_name: str | None = None) -> None:
302
302
  ws.log_workspace_info()
303
303
  else:
304
304
  ws = current_ws(silent=True)
305
- os.chdir(ws.base_dir)
306
305
  ws.log_workspace_info()
307
306
 
308
307
 
kash/config/colors.py CHANGED
@@ -136,8 +136,8 @@ web_light_translucent = SimpleNamespace(
136
136
  bg=hsl_to_hex("hsla(44, 6%, 100%, 0.75)"),
137
137
  bg_solid=hsl_to_hex("hsla(44, 6%, 100%, 1)"),
138
138
  bg_header=hsl_to_hex("hsla(188, 42%, 70%, 0.2)"),
139
- bg_alt=hsl_to_hex("hsla(44, 28%, 90%, 0.3)"),
140
- bg_alt_solid=hsl_to_hex("hsla(44, 28%, 97%, 1)"),
139
+ bg_alt=hsl_to_hex("hsla(39, 24%, 90%, 0.3)"),
140
+ bg_alt_solid=hsl_to_hex("hsla(39, 24%, 97%, 1)"),
141
141
  text=hsl_to_hex("hsl(188, 39%, 11%)"),
142
142
  border=hsl_to_hex("hsl(188, 8%, 50%)"),
143
143
  border_hint=hsl_to_hex("hsla(188, 8%, 72%, 0.7)"),
@@ -174,7 +174,7 @@ def _merge_param_declarations(
174
174
  merged_params[fp.name] = Param(
175
175
  name=fp.name,
176
176
  description=None,
177
- type=fp.type or str,
177
+ type=fp.effective_type or str,
178
178
  default_value=fp.default if fp.has_default else None,
179
179
  is_explicit=not fp.has_default,
180
180
  )
@@ -248,13 +248,13 @@ def kash_action(
248
248
 
249
249
  # Inspect and sanity check the formal params.
250
250
  func_params = inspect_function_params(orig_func)
251
- if len(func_params) == 0 or func_params[0].type not in (ActionInput, Item):
251
+ if len(func_params) == 0 or func_params[0].effective_type not in (ActionInput, Item):
252
252
  raise InvalidDefinition(
253
253
  f"Decorator `@kash_action` requires exactly one positional parameter, "
254
254
  f"`input` of type `ActionInput` or `Item` on function `{orig_func.__name__}` but "
255
255
  f"got params: {func_params}"
256
256
  )
257
- if any(fp.is_positional for fp in func_params[1:]):
257
+ if any(fp.is_pure_positional for fp in func_params[1:]):
258
258
  raise InvalidDefinition(
259
259
  "Decorator `@kash_action` requires all parameters after the first positional "
260
260
  f"parameter to be keyword parameters on function `{orig_func.__name__}` but "
@@ -265,7 +265,7 @@ def kash_action(
265
265
  context_param = next((fp for fp in func_params if fp.name == "context"), None)
266
266
  if context_param:
267
267
  func_params.remove(context_param)
268
- if context_param and context_param.is_positional:
268
+ if context_param and context_param.is_pure_positional:
269
269
  raise InvalidDefinition(
270
270
  "Decorator `@kash_action` requires the `context` parameter to be a keyword "
271
271
  "parameter, not positional, on function `{func.__name__}`"
@@ -273,7 +273,7 @@ def kash_action(
273
273
 
274
274
  # If the original function is a simple action function (processes a single item),
275
275
  # wrap it to convert to an ActionFunction.
276
- is_simple_func = func_params[0].type == Item
276
+ is_simple_func = func_params[0].effective_type == Item
277
277
  action_func: ActionFunction
278
278
  if is_simple_func:
279
279
  simple_func = cast(SimpleActionFunction, orig_func)
@@ -333,7 +333,7 @@ def kash_action(
333
333
  if context_param:
334
334
  kw_args["context"] = context
335
335
  for fp in func_params[1:]:
336
- if fp.is_positional:
336
+ if fp.is_pure_positional:
337
337
  pos_args.append(self.get_param(fp.name))
338
338
  else:
339
339
  kw_args[fp.name] = self.get_param(fp.name)
@@ -12,7 +12,8 @@ from kash.llm_utils.fuzzy_parsing import strip_markdown_fence
12
12
  from kash.llm_utils.llm_completion import llm_template_completion
13
13
  from kash.llm_utils.llm_messages import Message, MessageTemplate
14
14
  from kash.model.actions_model import LLMOptions
15
- from kash.model.items_model import Item, ItemType
15
+ from kash.model.items_model import Item
16
+ from kash.text_handling.doc_normalization import normalize_formatting_ansi
16
17
  from kash.utils.errors import InvalidInput
17
18
  from kash.utils.file_utils.file_formats_model import Format
18
19
 
@@ -90,6 +91,7 @@ def llm_transform_item(
90
91
  normalize: bool = True,
91
92
  strip_fence: bool = True,
92
93
  check_no_results: bool = True,
94
+ format: Format | None = None,
93
95
  ) -> Item:
94
96
  """
95
97
  Main function for running an LLM action on an item.
@@ -110,12 +112,13 @@ def llm_transform_item(
110
112
  log.message("LLM transform from action `%s` on item: %s", action.name, item)
111
113
  log.message("LLM options: %s", action.llm_options)
112
114
 
113
- result_item = item.derived_copy(type=ItemType.doc, body=None, format=Format.markdown)
115
+ format = format or item.format or Format.markdown
116
+ result_item = item.derived_copy(body=None, format=format)
114
117
  result_str = llm_transform_str(llm_options, item.body, check_no_results=check_no_results)
115
118
  if strip_fence:
116
119
  result_str = strip_markdown_fence(result_str)
117
120
  if normalize:
118
- result_str = fill_markdown(result_str)
121
+ result_str = normalize_formatting_ansi(result_str, format=format)
119
122
 
120
123
  result_item.body = result_str
121
124
  return result_item
@@ -8,6 +8,7 @@ from chopdiff.html import has_timestamp
8
8
  from kash.exec.precondition_registry import kash_precondition
9
9
  from kash.model.items_model import Item, ItemType
10
10
  from kash.text_handling.markdown_utils import extract_bullet_points
11
+ from kash.utils.file_utils.file_formats import is_full_html_page
11
12
  from kash.utils.file_utils.file_formats_model import Format
12
13
 
13
14
 
@@ -93,6 +94,11 @@ def has_html_body(item: Item) -> bool:
93
94
  return has_body(item) and item.format in (Format.html, Format.md_html)
94
95
 
95
96
 
97
+ @kash_precondition
98
+ def has_full_html_page_body(item: Item) -> bool:
99
+ return bool(has_html_body(item) and item.body and is_full_html_page(item.body))
100
+
101
+
96
102
  @kash_precondition
97
103
  def is_plaintext(item: Item) -> bool:
98
104
  return has_body(item) and item.format == Format.plaintext
kash/exec/resolve_args.py CHANGED
@@ -105,6 +105,10 @@ def assemble_action_args(
105
105
 
106
106
 
107
107
  def resolvable_paths(paths: Sequence[StorePath | Path]) -> list[StorePath]:
108
+ """
109
+ Return which of the given StorePaths are resolvable (exist) in the
110
+ current workspace.
111
+ """
108
112
  ws = current_ws()
109
113
  resolvable = list(filter(None, (ws.resolve_path(p) for p in paths)))
110
114
  return resolvable
@@ -450,19 +450,11 @@ class FileStore(Workspace):
450
450
  if not path.exists():
451
451
  raise FileNotFound(f"File not found: {fmt_loc(path)}")
452
452
 
453
- # It's a path outside the store, so copy it in.
454
- _name, filename_item_type, format, _file_ext = parse_item_filename(path)
455
-
456
- # Best guesses on item types if not specified.
457
- item_type = as_type
458
- if not item_type and filename_item_type:
459
- item_type = filename_item_type
460
- if not item_type and format:
461
- item_type = ItemType.for_format(format)
462
- if not item_type:
463
- item_type = ItemType.resource
464
-
465
- if format and format.supports_frontmatter:
453
+ # First treat it as an external file to analyze file type and format.
454
+ item = Item.from_external_path(path)
455
+
456
+ # If it's a text/frontmatter-friendly, read it fully.
457
+ if item.format and item.format.supports_frontmatter:
466
458
  log.message("Importing text file: %s", fmt_loc(path))
467
459
  # This will read the file with or without frontmatter.
468
460
  # We are importing so we want to drop the external path so we save the body.
@@ -486,15 +478,25 @@ class FileStore(Workspace):
486
478
  log.message("Importing non-text file: %s", fmt_loc(path))
487
479
  # Binary or other files we just copy over as-is, preserving the name.
488
480
  # We know the extension is recognized.
489
- item = Item.from_external_path(path)
490
- store_path, _found, _prev = self.store_path_for(item)
481
+ store_path, _found, old_store_path = self.store_path_for(item)
491
482
  if self.exists(store_path):
492
483
  raise FileExists(f"Resource already in store: {fmt_loc(store_path)}")
493
484
 
494
- item.type = item_type
495
-
496
- log.message("Importing resource: %s -> %s", fmt_loc(path), fmt_loc(store_path))
485
+ log.message("Importing resource: %s", fmt_loc(path))
497
486
  copyfile_atomic(path, self.base_dir / store_path, make_parents=True)
487
+
488
+ # Optimization: Don't import an identical file twice.
489
+ if old_store_path:
490
+ old_hash = self.hash(old_store_path)
491
+ new_hash = self.hash(store_path)
492
+ if old_hash == new_hash:
493
+ log.message(
494
+ "Imported resource is identical to the previous import: %s",
495
+ fmt_loc(old_store_path),
496
+ )
497
+ os.unlink(self.base_dir / store_path)
498
+ store_path = old_store_path
499
+ log.message("Imported resource: %s", fmt_loc(store_path))
498
500
  return store_path
499
501
 
500
502
  def import_items(
@@ -12,7 +12,7 @@ def _look_up_param_docs(func: Callable[..., Any], kw_params: list[FuncParam]) ->
12
12
  name = func_param.name
13
13
  param = ALL_COMMON_PARAMS.get(name)
14
14
  if not param:
15
- param = Param(name, description=None, type=func_param.type or str)
15
+ param = Param(name, description=None, type=func_param.effective_type or str)
16
16
 
17
17
  # Also check the docstring for a description of this parameter.
18
18
  docstring = parse_docstring(func.__doc__ or "")
@@ -18,7 +18,6 @@ from kash.shell.file_icons.nerd_icons import icon_for_file
18
18
  from kash.shell.output.shell_output import Wrap
19
19
  from kash.utils.common.type_utils import not_none
20
20
  from kash.utils.errors import FileNotFound, InvalidFilename
21
- from kash.web_gen import base_templates_dir
22
21
  from kash.web_gen.template_render import render_web_template
23
22
  from kash.workspaces.workspace_output import print_file_info
24
23
 
@@ -140,14 +139,11 @@ def explain(text: str):
140
139
 
141
140
  return HTMLResponse(
142
141
  render_web_template(
143
- base_templates_dir,
144
142
  "base_webpage.html.jinja",
145
143
  {
146
144
  "title": f"Help: {text}",
147
145
  "content": render_web_template(
148
- base_templates_dir,
149
- "explain_view.html.jinja",
150
- {"help_html": help_html, "page_url": page_url},
146
+ "explain_view.html.jinja", {"help_html": help_html, "page_url": page_url}
151
147
  ),
152
148
  },
153
149
  )
@@ -270,12 +266,10 @@ def _serve_item(
270
266
 
271
267
  return HTMLResponse(
272
268
  render_web_template(
273
- base_templates_dir,
274
269
  "base_webpage.html.jinja",
275
270
  {
276
271
  "title": display_title,
277
272
  "content": render_web_template(
278
- base_templates_dir,
279
273
  "item_view.html.jinja",
280
274
  {
281
275
  "item": item,