kash-shell 0.3.12__py3-none-any.whl → 0.3.13__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 (59) hide show
  1. kash/actions/core/markdownify.py +5 -4
  2. kash/actions/core/readability.py +4 -4
  3. kash/actions/core/render_as_html.py +6 -4
  4. kash/commands/base/basic_file_commands.py +3 -0
  5. kash/commands/base/diff_commands.py +38 -3
  6. kash/commands/base/reformat_command.py +1 -1
  7. kash/commands/base/show_command.py +1 -1
  8. kash/commands/workspace/selection_commands.py +1 -1
  9. kash/commands/workspace/workspace_commands.py +62 -16
  10. kash/docs/load_source_code.py +1 -1
  11. kash/exec/action_exec.py +4 -5
  12. kash/exec/fetch_url_metadata.py +8 -5
  13. kash/exec/importing.py +4 -4
  14. kash/exec/llm_transforms.py +1 -1
  15. kash/exec/preconditions.py +7 -7
  16. kash/file_storage/file_store.py +73 -32
  17. kash/file_storage/item_file_format.py +1 -1
  18. kash/file_storage/store_filenames.py +2 -1
  19. kash/help/help_embeddings.py +2 -2
  20. kash/llm_utils/clean_headings.py +1 -1
  21. kash/{text_handling → llm_utils}/custom_sliding_transforms.py +0 -3
  22. kash/llm_utils/llm_completion.py +1 -1
  23. kash/local_server/__init__.py +1 -1
  24. kash/local_server/local_server_commands.py +2 -1
  25. kash/mcp/__init__.py +1 -1
  26. kash/mcp/mcp_server_commands.py +8 -2
  27. kash/media_base/media_cache.py +10 -3
  28. kash/model/actions_model.py +3 -0
  29. kash/model/items_model.py +71 -42
  30. kash/shell/ui/shell_results.py +2 -1
  31. kash/utils/common/format_utils.py +0 -8
  32. kash/utils/common/import_utils.py +46 -18
  33. kash/utils/file_utils/file_formats_model.py +46 -26
  34. kash/utils/file_utils/filename_parsing.py +41 -16
  35. kash/{text_handling → utils/text_handling}/doc_normalization.py +10 -8
  36. kash/utils/text_handling/escape_html_tags.py +156 -0
  37. kash/{text_handling → utils/text_handling}/markdown_utils.py +0 -3
  38. kash/utils/text_handling/markdownify_utils.py +87 -0
  39. kash/{text_handling → utils/text_handling}/unified_diffs.py +1 -44
  40. kash/web_content/file_cache_utils.py +42 -34
  41. kash/web_content/local_file_cache.py +29 -12
  42. kash/web_content/web_extract.py +1 -1
  43. kash/web_content/web_extract_readabilipy.py +4 -2
  44. kash/web_content/web_fetch.py +42 -7
  45. kash/web_content/web_page_model.py +2 -1
  46. kash/web_gen/simple_webpage.py +1 -1
  47. kash/web_gen/templates/base_styles.css.jinja +134 -16
  48. kash/web_gen/templates/simple_webpage.html.jinja +1 -1
  49. kash/workspaces/selections.py +2 -2
  50. kash/workspaces/workspace_importing.py +1 -1
  51. kash/workspaces/workspace_output.py +2 -2
  52. kash/xonsh_custom/load_into_xonsh.py +4 -2
  53. {kash_shell-0.3.12.dist-info → kash_shell-0.3.13.dist-info}/METADATA +1 -1
  54. {kash_shell-0.3.12.dist-info → kash_shell-0.3.13.dist-info}/RECORD +58 -57
  55. kash/utils/common/inflection.py +0 -22
  56. /kash/{text_handling → utils/text_handling}/markdown_render.py +0 -0
  57. {kash_shell-0.3.12.dist-info → kash_shell-0.3.13.dist-info}/WHEEL +0 -0
  58. {kash_shell-0.3.12.dist-info → kash_shell-0.3.13.dist-info}/entry_points.txt +0 -0
  59. {kash_shell-0.3.12.dist-info → kash_shell-0.3.13.dist-info}/licenses/LICENSE +0 -0
@@ -182,12 +182,37 @@ class FileStore(Workspace):
182
182
  except (FileNotFoundError, InvalidFilename):
183
183
  pass
184
184
 
185
+ def resolve_path(self, path: Path | StorePath) -> StorePath | None:
186
+ """
187
+ Return a StorePath if the given path is within the store, otherwise None.
188
+ If it is already a StorePath, return it unchanged.
189
+ """
190
+ if isinstance(path, StorePath):
191
+ return path
192
+ resolved = path.resolve()
193
+ if resolved.is_relative_to(self.base_dir):
194
+ return StorePath(resolved.relative_to(self.base_dir))
195
+ else:
196
+ return None
197
+
198
+ def exists(self, store_path: StorePath) -> bool:
199
+ """
200
+ Check given store path refers to an existing file.
201
+ """
202
+ return (self.base_dir / store_path).exists()
203
+
185
204
  @synchronized
186
- def _new_filename_for(self, item: Item) -> tuple[str, str | None]:
205
+ def _pick_filename_for(self, item: Item, *, overwrite: bool = False) -> tuple[str, str | None]:
187
206
  """
188
- Get a suitable filename for this item that is close to the slugified title yet also unique.
189
- Also return the old filename if it's different.
207
+ Get a suitable filename for this item.
208
+ If `overwrite` is true, use the the slugified title.
209
+ If it is false, use the slugified title with a suffix to make it unique
210
+ and in this case returns the old filename for this item, if it is different.
190
211
  """
212
+ if overwrite:
213
+ log.info("Picked default filename: %s for item: %s", item.default_filename(), item)
214
+ return item.default_filename(), None
215
+
191
216
  slug = item.slug_name()
192
217
  full_suffix = item.get_full_suffix()
193
218
  # Get a unique name per item type.
@@ -198,29 +223,15 @@ class FileStore(Workspace):
198
223
 
199
224
  old_filename = join_suffix(old_slugs[0], full_suffix) if old_slugs else None
200
225
 
226
+ log.info("Picked new unique filename: %s for item: %s", new_unique_filename, item)
201
227
  return new_unique_filename, old_filename
202
228
 
203
- def _default_path_for(self, item: Item) -> StorePath:
204
- folder_path = folder_for_type(item.type)
205
- slug = item.slug_name()
206
- suffix = item.get_full_suffix()
207
- return StorePath(folder_path / join_suffix(slug, suffix))
208
-
209
- def exists(self, store_path: StorePath) -> bool:
210
- return (self.base_dir / store_path).exists()
211
-
212
- def resolve_path(self, path: Path | StorePath) -> StorePath | None:
229
+ def default_path_for(self, item: Item) -> StorePath:
213
230
  """
214
- Return a StorePath if the given path is within the store, otherwise None.
215
- If it is already a StorePath, return it unchanged.
231
+ Get the default store path for an item based on slugifying its title or other metadata.
216
232
  """
217
- if isinstance(path, StorePath):
218
- return path
219
- resolved = path.resolve()
220
- if resolved.is_relative_to(self.base_dir):
221
- return StorePath(resolved.relative_to(self.base_dir))
222
- else:
223
- return None
233
+ folder_path = folder_for_type(item.type)
234
+ return StorePath(folder_path / item.default_filename())
224
235
 
225
236
  @synchronized
226
237
  def find_by_id(self, item: Item) -> StorePath | None:
@@ -235,7 +246,7 @@ class FileStore(Workspace):
235
246
  store_path = self.id_map.get(item_id)
236
247
  if not store_path:
237
248
  # Just in case the id_map is not complete, check the default path too.
238
- default_path = self._default_path_for(item)
249
+ default_path = self.default_path_for(item)
239
250
  if self.exists(default_path):
240
251
  old_item = self.load(default_path)
241
252
  if old_item.item_id() == item_id:
@@ -256,7 +267,7 @@ class FileStore(Workspace):
256
267
 
257
268
  @synchronized
258
269
  def store_path_for(
259
- self, item: Item, as_tmp: bool = False
270
+ self, item: Item, *, as_tmp: bool = False, overwrite: bool = False
260
271
  ) -> tuple[StorePath, bool, StorePath | None]:
261
272
  """
262
273
  Return the store path for an item. If the item already has a `store_path`, we use that.
@@ -265,6 +276,10 @@ class FileStore(Workspace):
265
276
  Returns `store_path, found, old_store_path` where `found` indicates whether the path was
266
277
  already found (in the item or in the store by checking for identity) and `old_store_path`
267
278
  is the previous similarly named item with a different identity (or None there is none).
279
+
280
+ If `as_tmp` is true, will return a path from the temporary directory in the store.
281
+ Normally an item is always saved to a unique store path but if `overwrite` is true,
282
+ will always save to the same path
268
283
  """
269
284
  item_id = item.item_id()
270
285
  old_filename = None
@@ -281,9 +296,9 @@ class FileStore(Workspace):
281
296
  )
282
297
  return store_path, True, None
283
298
  else:
284
- # We need to generate a new filename.
299
+ # We need to pick the path and filename.
285
300
  folder_path = folder_for_type(item.type)
286
- filename, old_filename = self._new_filename_for(item)
301
+ filename, old_filename = self._pick_filename_for(item, overwrite=overwrite)
287
302
  store_path = folder_path / filename
288
303
 
289
304
  old_store_path = None
@@ -312,7 +327,13 @@ class FileStore(Workspace):
312
327
 
313
328
  @log_calls()
314
329
  def save(
315
- self, item: Item, *, overwrite: bool = True, as_tmp: bool = False, no_format: bool = False
330
+ self,
331
+ item: Item,
332
+ *,
333
+ overwrite: bool = False,
334
+ skip_dup_names: bool = False,
335
+ as_tmp: bool = False,
336
+ no_format: bool = False,
316
337
  ) -> StorePath:
317
338
  """
318
339
  Save the item. Uses the `store_path` if it's already set or generates a new one.
@@ -321,9 +342,18 @@ class FileStore(Workspace):
321
342
  Unless `no_format` is true, also normalizes body text formatting (for Markdown)
322
343
  and updates the item's body to match.
323
344
 
345
+ If `overwrite` is true, will overwrite a file that has the same path.
346
+
324
347
  If `as_tmp` is true, will save the item to a temporary file.
325
- If `overwrite` is false, will skip saving if the item already exists.
348
+
349
+ If `skip_dup_names` is true, will skip saving if an item if an item with a
350
+ matching path (based on its title) already exists.
326
351
  """
352
+ if overwrite and skip_dup_names:
353
+ raise ValueError("Cannot both overwrite and skip duplicate names.")
354
+ if overwrite and as_tmp:
355
+ raise ValueError("Cannot both overwrite and save to a temporary file.")
356
+
327
357
  # If external file already exists within the workspace, the file is already saved (without metadata).
328
358
  external_path = item.external_path and Path(item.external_path).resolve()
329
359
  if external_path and self._is_in_store(external_path):
@@ -335,10 +365,15 @@ class FileStore(Workspace):
335
365
  return StorePath(rel_path)
336
366
  else:
337
367
  # Otherwise it's still in memory or in a file outside the workspace and we need to save it.
338
- store_path, found, old_store_path = self.store_path_for(item, as_tmp=as_tmp)
368
+ store_path, found, old_store_path = self.store_path_for(
369
+ item, as_tmp=as_tmp, overwrite=overwrite
370
+ )
339
371
 
340
- if not overwrite and found:
341
- log.message("Skipping save of item already saved: %s", fmt_loc(store_path))
372
+ if skip_dup_names and found:
373
+ log.message(
374
+ "Skipping save because an item of the same name already exists: %s",
375
+ fmt_loc(store_path),
376
+ )
342
377
  item.store_path = str(store_path)
343
378
  return store_path
344
379
 
@@ -356,8 +391,14 @@ class FileStore(Workspace):
356
391
  # Now save the new item.
357
392
  try:
358
393
  if item.external_path:
359
- copyfile_atomic(item.external_path, full_path)
394
+ copyfile_atomic(item.external_path, full_path, make_parents=True)
360
395
  else:
396
+ if overwrite and full_path.exists():
397
+ log.info(
398
+ "Overwrite is enabled and a previous file exists so will archive it: %s",
399
+ fmt_loc(store_path),
400
+ )
401
+ self.archive(store_path, quiet=True)
361
402
  write_item(item, full_path, normalize=not no_format)
362
403
  except OSError as e:
363
404
  log.error("Error saving item: %s", e)
@@ -7,10 +7,10 @@ from prettyfmt import custom_key_sort, fmt_size_human
7
7
  from kash.config.logger import get_logger
8
8
  from kash.model.items_model import ITEM_FIELDS, Item
9
9
  from kash.model.operations_model import OPERATION_FIELDS
10
- from kash.text_handling.doc_normalization import normalize_formatting
11
10
  from kash.utils.common.format_utils import fmt_loc
12
11
  from kash.utils.file_utils.file_formats_model import Format
13
12
  from kash.utils.file_utils.mtime_cache import MtimeCache
13
+ from kash.utils.text_handling.doc_normalization import normalize_formatting
14
14
 
15
15
  log = get_logger(__name__)
16
16
 
@@ -1,9 +1,10 @@
1
1
  from functools import cache
2
2
  from pathlib import Path
3
3
 
4
+ from prettyfmt import plural
5
+
4
6
  from kash.config.logger import get_logger
5
7
  from kash.model.items_model import ItemType
6
- from kash.utils.common.inflection import plural
7
8
  from kash.utils.file_utils.file_formats_model import FileExt, Format
8
9
  from kash.utils.file_utils.filename_parsing import split_filename
9
10
 
@@ -62,9 +62,9 @@ class HelpIndex:
62
62
  embeddings.to_npz(target_path)
63
63
 
64
64
  key = f"kash_help_embeddings_v1_{len(self.docs)}.npz"
65
- path, _ = cache_file(
65
+ path = cache_file(
66
66
  Loadable(key, save=calculate_and_save_help_embeddings), global_cache=True
67
- )
67
+ ).content.path
68
68
  log.info("Loaded help doc embeddings from: %s", path)
69
69
  return Embeddings.read_from_npz(path)
70
70
 
@@ -1,6 +1,6 @@
1
1
  from kash.llm_utils import Message, MessageTemplate, llm_template_completion
2
2
  from kash.llm_utils.llms import LLM
3
- from kash.text_handling.markdown_utils import as_bullet_points
3
+ from kash.utils.text_handling.markdown_utils import as_bullet_points
4
4
 
5
5
  # TODO: Enforce that the edits below doesn't contain anything extraneous.
6
6
 
@@ -31,9 +31,6 @@ log = get_logger(__name__)
31
31
  # more customized logging and task stack.
32
32
 
33
33
 
34
- log = get_logger(__name__)
35
-
36
-
37
34
  def filtered_transform(
38
35
  doc: TextDoc,
39
36
  transform_func: TextDocTransform,
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ from dataclasses import dataclass
4
5
  from typing import TYPE_CHECKING, cast
5
6
 
6
7
  from flowmark import Wrap, fill_text
7
8
  from funlog import format_duration, log_calls
8
9
  from prettyfmt import slugify_snake
9
10
  from pydantic import BaseModel
10
- from pydantic.dataclasses import dataclass
11
11
 
12
12
  from kash.config.logger import get_logger
13
13
  from kash.config.text_styles import EMOJI_TIMING
@@ -2,4 +2,4 @@ from pathlib import Path
2
2
 
3
3
  from kash.exec.importing import import_and_register
4
4
 
5
- import_and_register(__package__, Path(__file__).parent, ["."])
5
+ import_and_register(__package__, Path(__file__).parent, ["local_server_commands"])
@@ -1,7 +1,6 @@
1
1
  from kash.config.logger import get_logger
2
2
  from kash.config.settings import global_settings
3
3
  from kash.exec import kash_command
4
- from kash.local_server.local_url_formatters import enable_local_urls
5
4
  from kash.shell.utils.native_utils import tail_file
6
5
  from kash.utils.errors import InvalidState
7
6
 
@@ -17,6 +16,7 @@ def start_ui_server() -> None:
17
16
  tooltips.
18
17
  """
19
18
  from kash.local_server.local_server import start_ui_server
19
+ from kash.local_server.local_url_formatters import enable_local_urls
20
20
 
21
21
  start_ui_server()
22
22
  enable_local_urls(True)
@@ -28,6 +28,7 @@ def stop_ui_server() -> None:
28
28
  Stop the kash local server.
29
29
  """
30
30
  from kash.local_server.local_server import stop_ui_server
31
+ from kash.local_server.local_url_formatters import enable_local_urls
31
32
 
32
33
  stop_ui_server()
33
34
  enable_local_urls(False)
kash/mcp/__init__.py CHANGED
@@ -2,4 +2,4 @@ from pathlib import Path
2
2
 
3
3
  from kash.exec.importing import import_and_register
4
4
 
5
- import_and_register(__package__, Path(__file__).parent, ["."])
5
+ import_and_register(__package__, Path(__file__).parent, ["mcp_server_commands"])
@@ -6,8 +6,6 @@ from kash.config.settings import (
6
6
  global_settings,
7
7
  )
8
8
  from kash.exec import kash_command
9
- from kash.mcp import mcp_server_routes
10
- from kash.mcp.mcp_cli import MCP_CLI_LOG_PATH
11
9
  from kash.shell.output.shell_formatting import format_name_and_value
12
10
  from kash.shell.output.shell_output import cprint, print_h2
13
11
  from kash.shell.utils.native_utils import tail_file
@@ -21,6 +19,7 @@ def start_mcp_server() -> None:
21
19
  """
22
20
  Start the MCP server, using all currently known actions marked as MCP tools.
23
21
  """
22
+ from kash.mcp import mcp_server_routes
24
23
  from kash.mcp.mcp_server_sse import start_mcp_server_sse
25
24
 
26
25
  mcp_server_routes.publish_mcp_tools()
@@ -42,6 +41,7 @@ def restart_mcp_server() -> None:
42
41
  """
43
42
  Restart the MCP server, republishing all actions marked as MCP tools.
44
43
  """
44
+ from kash.mcp import mcp_server_routes
45
45
  from kash.mcp.mcp_server_sse import restart_mcp_server_sse
46
46
 
47
47
  mcp_server_routes.unpublish_mcp_tools(None)
@@ -57,6 +57,8 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
57
57
  :param follow: Follow the file as it grows.
58
58
  :param all: Show all logs, not just the server logs, including Claude Desktop logs if found.
59
59
  """
60
+ from kash.mcp.mcp_cli import MCP_CLI_LOG_PATH
61
+
60
62
  settings = global_settings()
61
63
  if all:
62
64
  global_log_base = settings.system_logs_dir
@@ -96,6 +98,7 @@ def list_mcp_tools() -> None:
96
98
  """
97
99
  List published MCP tools.
98
100
  """
101
+ from kash.mcp import mcp_server_routes
99
102
 
100
103
  tools = mcp_server_routes.get_published_tools()
101
104
 
@@ -118,6 +121,8 @@ def publish_mcp_tool(*action_names: str) -> None:
118
121
  Publish one or more actions as local MCP tools. With no arguments, publish all
119
122
  actions marked as MCP tools.
120
123
  """
124
+ from kash.mcp import mcp_server_routes
125
+
121
126
  if not action_names:
122
127
  log.message("Publishing all actions marked as MCP tools.")
123
128
  mcp_server_routes.publish_mcp_tools()
@@ -131,6 +136,7 @@ def unpublish_mcp_tool(*action_names: str) -> None:
131
136
  Un-publish one or more actions as local MCP tools. With no arguments,
132
137
  un-publish all published actions.
133
138
  """
139
+ from kash.mcp import mcp_server_routes
134
140
 
135
141
  if not action_names:
136
142
  log.message("Un-publishing all actions marked as MCP tools.")
@@ -1,4 +1,5 @@
1
1
  import os
2
+ from functools import cache
2
3
  from pathlib import Path
3
4
 
4
5
  from prettyfmt import fmt_lines, fmt_path
@@ -11,7 +12,6 @@ from kash.media_base.media_services import (
11
12
  download_media_by_service,
12
13
  get_media_services,
13
14
  )
14
- from kash.media_base.transcription_deepgram import deepgram_transcribe_audio
15
15
  from kash.utils.common.format_utils import fmt_loc
16
16
  from kash.utils.common.url import Url, as_file_url, is_url
17
17
  from kash.utils.errors import FileNotFound, InvalidInput, UnexpectedError
@@ -22,7 +22,14 @@ log = get_logger(__name__)
22
22
 
23
23
  # FIXME: Hard-coded dependency for now. Would be better to make it settable.
24
24
  # transcribe_audio = whisper_transcribe_audio_small
25
- transcribe_audio = deepgram_transcribe_audio
25
+
26
+
27
+ @cache
28
+ def get_transcriber():
29
+ from kash.media_base.transcription_deepgram import deepgram_transcribe_audio
30
+
31
+ transcribe_audio = deepgram_transcribe_audio
32
+ return transcribe_audio
26
33
 
27
34
 
28
35
  # For simplicity we assume all audio is converted to mp3.
@@ -83,7 +90,7 @@ class MediaCache(DirStore):
83
90
  url,
84
91
  fmt_path(downsampled_audio_file),
85
92
  )
86
- transcript = transcribe_audio(downsampled_audio_file, language=language)
93
+ transcript = get_transcriber()(downsampled_audio_file, language=language)
87
94
  self._write_transcript(url, transcript)
88
95
  return transcript
89
96
 
@@ -90,6 +90,9 @@ class ActionResult:
90
90
  replaces_input: bool = False
91
91
  """If True, a hint to archive the input items."""
92
92
 
93
+ overwrite: bool = False
94
+ """If True, will not pick unique output paths to save to, overwriting existing files of the same name."""
95
+
93
96
  skip_duplicates: bool = False
94
97
  """If True, do not save duplicate items (based on identity)."""
95
98
 
kash/model/items_model.py CHANGED
@@ -24,13 +24,14 @@ from kash.model.concept_model import canonicalize_concept
24
24
  from kash.model.media_model import MediaMetadata
25
25
  from kash.model.operations_model import OperationSummary, Source
26
26
  from kash.model.paths_model import StorePath, fmt_store_path
27
- from kash.text_handling.markdown_render import markdown_to_html
28
- from kash.text_handling.markdown_utils import first_heading
29
27
  from kash.utils.common.format_utils import fmt_loc, html_to_plaintext, plaintext_to_html
30
28
  from kash.utils.common.url import Locator, Url
31
29
  from kash.utils.errors import FileFormatError
32
30
  from kash.utils.file_formats.chat_format import ChatHistory
31
+ from kash.utils.file_utils.file_formats import MimeType
33
32
  from kash.utils.file_utils.file_formats_model import FileExt, Format
33
+ from kash.utils.text_handling.markdown_render import markdown_to_html
34
+ from kash.utils.text_handling.markdown_utils import first_heading
34
35
 
35
36
  if TYPE_CHECKING:
36
37
  from kash.model.exec_model import ExecContext
@@ -359,15 +360,19 @@ class Item:
359
360
  cls,
360
361
  path: Path | str,
361
362
  item_type: ItemType | None = None,
363
+ *,
362
364
  title: str | None = None,
365
+ original_filename: str | None = None,
366
+ mime_type: MimeType | None = None,
363
367
  ) -> Item:
364
368
  """
365
369
  Create a resource Item for a file with a format inferred from the file extension
366
370
  or the content. Only sets basic metadata. Does not read the content. Will set
367
371
  `format` and `file_ext` if possible but will leave them as None if unrecognized.
372
+ If `mime_type` is provided, it can help determine the file extension.
368
373
  """
369
374
  from kash.file_storage.store_filenames import parse_item_filename
370
- from kash.utils.file_utils.file_formats_model import detect_file_format
375
+ from kash.utils.file_utils.file_formats_model import choose_file_ext, detect_file_format
371
376
 
372
377
  # Will raise error for unrecognized file ext.
373
378
  _name, filename_item_type, format, file_ext = parse_item_filename(path)
@@ -380,12 +385,17 @@ class Item:
380
385
  item_type = (
381
386
  ItemType.doc if format and format.supports_frontmatter else ItemType.resource
382
387
  )
388
+ # Do our best to determine a good file extension if it's not already on the filename.
389
+ if not file_ext and mime_type:
390
+ file_ext = choose_file_ext(path, mime_type)
391
+
383
392
  item = cls(
384
393
  type=item_type,
385
394
  title=title,
386
395
  file_ext=file_ext,
387
396
  format=format,
388
397
  external_path=str(path),
398
+ original_filename=original_filename,
389
399
  )
390
400
 
391
401
  # Update modified time from the file system.
@@ -507,17 +517,43 @@ class Item:
507
517
 
508
518
  return item_dict
509
519
 
510
- def display_title(self) -> str:
520
+ def filename_stem(self) -> str | None:
511
521
  """
512
- A display title for this item. Same as abbrev_title() but will fall back
513
- to the filename if it is available.
522
+ If the item has an existing or previous filename, return its stem,
523
+ for use in picking new filenames.
514
524
  """
515
- display_title = self.title
516
- if not display_title and self.store_path:
517
- display_title = Path(self.store_path).name
518
- if not display_title:
519
- display_title = self.abbrev_title()
520
- return display_title
525
+ from kash.file_storage.store_filenames import parse_item_filename
526
+
527
+ # Prefer original to external, e.g. if we know the original but the external might
528
+ # be a cache filename.
529
+ path = self.store_path or self.original_filename or self.external_path
530
+ if path:
531
+ path_name, _item_type, _format, _file_ext = parse_item_filename(Path(path).name)
532
+ else:
533
+ path_name = None
534
+ return path_name
535
+
536
+ def slug_name(self, max_len: int = SLUG_MAX_LEN, prefer_title: bool = False) -> str:
537
+ """
538
+ Get a readable slugified name for this item, either from a previous filename
539
+ or from slugifying the title or content. May not be unique.
540
+ """
541
+ filename_stem = self.filename_stem()
542
+ if filename_stem and not prefer_title:
543
+ return slugify_snake(filename_stem)
544
+ else:
545
+ return slugify_snake(self.abbrev_title(max_len=max_len, add_ops_suffix=True))
546
+
547
+ def default_filename(self) -> str:
548
+ """
549
+ Get the default filename for an item based on slugifying its title or other
550
+ metadata. May not be unique.
551
+ """
552
+ from kash.file_storage.store_filenames import join_suffix
553
+
554
+ slug = self.slug_name()
555
+ full_suffix = self.get_full_suffix()
556
+ return join_suffix(slug, full_suffix)
521
557
 
522
558
  def abbrev_title(
523
559
  self,
@@ -527,12 +563,10 @@ class Item:
527
563
  pull_body_heading: bool = False,
528
564
  ) -> str:
529
565
  """
530
- Get or infer a title for this item, falling back to the filename, URL,
531
- description, or finally body text.
532
- Optionally, include the last operation as a parenthetical at the end of the title.
566
+ Get or infer a title for this item, falling back to the filename, URL, description, or
567
+ finally body text. Optionally, include the last operation as a parenthetical at the end
568
+ of the title. Will use "Untitled" if all else fails.
533
569
  """
534
- from kash.file_storage.store_filenames import parse_item_filename
535
-
536
570
  # First special case: if we are pulling the title from the body header, check
537
571
  # that.
538
572
  if not self.title and pull_body_heading:
@@ -544,18 +578,12 @@ class Item:
544
578
  if not self.title and self.url:
545
579
  return abbrev_str(self.url, max_len)
546
580
 
547
- # Special case for filenames with no title.
548
- # Use stem to drop suffix like .resource.docx etc in a title.
549
- path = self.store_path or self.external_path or self.original_filename
550
- if path:
551
- path_name, _item_type, _format, _file_ext = parse_item_filename(Path(path).name)
552
- else:
553
- path_name = None
581
+ filename_stem = self.filename_stem()
554
582
 
555
583
  # Use the title or the path if possible, falling back to description or even body text.
556
584
  title_raw_text = (
557
585
  self.title
558
- or path_name
586
+ or filename_stem
559
587
  or self.description
560
588
  or (not self.is_binary and self.abbrev_body(max_len))
561
589
  or UNTITLED
@@ -586,6 +614,24 @@ class Item:
586
614
 
587
615
  return final_text
588
616
 
617
+ def display_title(self) -> str:
618
+ """
619
+ A display title for this item. Same as abbrev_title() but will fall back
620
+ to the filename if it is available.
621
+ """
622
+ display_title = self.title
623
+ if not display_title and self.store_path:
624
+ display_title = Path(self.store_path).name
625
+ if not display_title:
626
+ display_title = self.abbrev_title()
627
+ return display_title
628
+
629
+ def abbrev_description(self, max_len: int = 1000) -> str:
630
+ """
631
+ Get or infer description.
632
+ """
633
+ return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
634
+
589
635
  def body_heading(self) -> str | None:
590
636
  """
591
637
  Get the first h1 or h2 heading from the body text, if present.
@@ -620,21 +666,6 @@ class Item:
620
666
  """
621
667
  return bool(self.body and self.body.strip())
622
668
 
623
- def slug_name(self, max_len: int = SLUG_MAX_LEN) -> str:
624
- """
625
- Get a readable slugified version of the title or filename or content
626
- appropriate for this item. May not be unique.
627
- """
628
- title = self.abbrev_title(max_len=max_len, add_ops_suffix=True)
629
- slug = slugify_snake(title)
630
- return slug
631
-
632
- def abbrev_description(self, max_len: int = 1000) -> str:
633
- """
634
- Get or infer description.
635
- """
636
- return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
637
-
638
669
  def read_as_config(self) -> Any:
639
670
  """
640
671
  If it is a config Item, return the parsed YAML.
@@ -653,8 +684,6 @@ class Item:
653
684
  """
654
685
  if self.file_ext:
655
686
  return self.file_ext
656
- if self.is_binary and not self.file_ext:
657
- raise ValueError(f"Binary Items must have a file extension: {self}")
658
687
  inferred_ext = self.format and self.format.file_ext
659
688
  if not inferred_ext:
660
689
  raise ValueError(f"Cannot infer file extension for Item: {self}")
@@ -1,5 +1,6 @@
1
1
  from typing import Any
2
2
 
3
+ from prettyfmt import fmt_count_items
3
4
  from rich.box import SQUARE
4
5
  from rich.panel import Panel
5
6
  from rich.table import Table
@@ -10,7 +11,7 @@ from kash.config.text_styles import COLOR_SELECTION, STYLE_HINT
10
11
  from kash.exec.command_exec import run_command_or_action
11
12
  from kash.exec_model.shell_model import ShellResult
12
13
  from kash.shell.output.shell_output import PrintHooks, console_pager, cprint, print_result
13
- from kash.utils.common.format_utils import fmt_count_items, fmt_loc
14
+ from kash.utils.common.format_utils import fmt_loc
14
15
  from kash.utils.errors import is_fatal
15
16
  from kash.workspaces import SelectionHistory
16
17
 
@@ -4,7 +4,6 @@ from pathlib import Path
4
4
 
5
5
  from prettyfmt import fmt_path
6
6
 
7
- from kash.utils.common.inflection import plural
8
7
  from kash.utils.common.url import Locator, is_url
9
8
 
10
9
 
@@ -44,13 +43,6 @@ def fmt_loc(locator: str | Locator, resolve: bool = True) -> str:
44
43
  return fmt_path(locator, resolve=resolve)
45
44
 
46
45
 
47
- def fmt_count_items(count: int, name: str = "item") -> str:
48
- """
49
- Format a count and a name as a pluralized phrase, e.g. "1 item" or "2 items".
50
- """
51
- return f"{count} {plural(name, count)}" # pyright: ignore
52
-
53
-
54
46
  ## Tests
55
47
 
56
48