kash-shell 0.3.14__py3-none-any.whl → 0.3.16__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/render_as_html.py +3 -1
- kash/actions/core/tabbed_webpage_generate.py +3 -1
- kash/commands/workspace/selection_commands.py +22 -0
- kash/commands/workspace/workspace_commands.py +11 -5
- kash/config/colors.py +87 -13
- kash/exec/action_exec.py +23 -4
- kash/exec/precondition_registry.py +5 -3
- kash/exec/preconditions.py +7 -2
- kash/exec/resolve_args.py +2 -2
- kash/file_storage/file_store.py +87 -54
- kash/file_storage/item_file_format.py +0 -2
- kash/local_server/local_server_routes.py +1 -1
- kash/model/actions_model.py +5 -1
- kash/model/items_model.py +39 -38
- kash/shell/completions/shell_completions.py +1 -1
- kash/utils/file_utils/file_formats_model.py +15 -7
- kash/utils/text_handling/doc_normalization.py +7 -0
- kash/web_gen/simple_webpage.py +4 -1
- kash/web_gen/tabbed_webpage.py +8 -3
- kash/web_gen/templates/base_styles.css.jinja +48 -5
- kash/web_gen/templates/base_webpage.html.jinja +128 -0
- kash/web_gen/templates/item_view.html.jinja +4 -2
- kash/web_gen/templates/simple_webpage.html.jinja +24 -0
- kash/xontrib/kash_extension.py +3 -2
- {kash_shell-0.3.14.dist-info → kash_shell-0.3.16.dist-info}/METADATA +1 -1
- {kash_shell-0.3.14.dist-info → kash_shell-0.3.16.dist-info}/RECORD +29 -29
- {kash_shell-0.3.14.dist-info → kash_shell-0.3.16.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.14.dist-info → kash_shell-0.3.16.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.14.dist-info → kash_shell-0.3.16.dist-info}/licenses/LICENSE +0 -0
kash/file_storage/file_store.py
CHANGED
|
@@ -16,7 +16,11 @@ from kash.config.logger import get_log_settings, get_logger
|
|
|
16
16
|
from kash.config.text_styles import EMOJI_SAVED
|
|
17
17
|
from kash.file_storage.item_file_format import read_item, write_item
|
|
18
18
|
from kash.file_storage.metadata_dirs import MetadataDirs
|
|
19
|
-
from kash.file_storage.store_filenames import
|
|
19
|
+
from kash.file_storage.store_filenames import (
|
|
20
|
+
folder_for_type,
|
|
21
|
+
join_suffix,
|
|
22
|
+
parse_item_filename,
|
|
23
|
+
)
|
|
20
24
|
from kash.model.items_model import Item, ItemId, ItemType
|
|
21
25
|
from kash.model.paths_model import StorePath
|
|
22
26
|
from kash.shell.output.shell_output import PrintHooks
|
|
@@ -39,7 +43,9 @@ T = TypeVar("T")
|
|
|
39
43
|
P = ParamSpec("P")
|
|
40
44
|
|
|
41
45
|
|
|
42
|
-
def synchronized(
|
|
46
|
+
def synchronized(
|
|
47
|
+
method: Callable[Concatenate[SelfT, P], T],
|
|
48
|
+
) -> Callable[Concatenate[SelfT, P], T]:
|
|
43
49
|
"""
|
|
44
50
|
Simple way to synchronize a few methods.
|
|
45
51
|
"""
|
|
@@ -142,7 +148,10 @@ class FileStore(Workspace):
|
|
|
142
148
|
"""
|
|
143
149
|
name, item_type, _format, file_ext = parse_item_filename(store_path)
|
|
144
150
|
if not file_ext:
|
|
145
|
-
log.debug(
|
|
151
|
+
log.debug(
|
|
152
|
+
"Skipping file with unrecognized name or extension: %s",
|
|
153
|
+
fmt_path(store_path),
|
|
154
|
+
)
|
|
146
155
|
return None
|
|
147
156
|
|
|
148
157
|
full_suffix = join_suffix(item_type.name, file_ext.name) if item_type else file_ext.name
|
|
@@ -158,11 +167,17 @@ class FileStore(Workspace):
|
|
|
158
167
|
if old_path and old_path != store_path:
|
|
159
168
|
dup_path = old_path
|
|
160
169
|
log.info(
|
|
161
|
-
"Duplicate items (%s):\n%s",
|
|
170
|
+
"Duplicate items (%s):\n%s",
|
|
171
|
+
item_id,
|
|
172
|
+
fmt_lines([old_path, store_path]),
|
|
162
173
|
)
|
|
163
174
|
self.id_map[item_id] = store_path
|
|
164
|
-
except SkippableError as e:
|
|
165
|
-
log.warning(
|
|
175
|
+
except (ValueError, SkippableError) as e:
|
|
176
|
+
log.warning(
|
|
177
|
+
"Could not load file, skipping from store index: %s: %s",
|
|
178
|
+
fmt_path(store_path),
|
|
179
|
+
e,
|
|
180
|
+
)
|
|
166
181
|
|
|
167
182
|
return dup_path
|
|
168
183
|
|
|
@@ -182,7 +197,7 @@ class FileStore(Workspace):
|
|
|
182
197
|
except (FileNotFoundError, InvalidFilename):
|
|
183
198
|
pass
|
|
184
199
|
|
|
185
|
-
def
|
|
200
|
+
def resolve_to_store_path(self, path: Path | StorePath) -> StorePath | None:
|
|
186
201
|
"""
|
|
187
202
|
Return a StorePath if the given path is within the store, otherwise None.
|
|
188
203
|
If it is already a StorePath, return it unchanged.
|
|
@@ -195,6 +210,21 @@ class FileStore(Workspace):
|
|
|
195
210
|
else:
|
|
196
211
|
return None
|
|
197
212
|
|
|
213
|
+
def resolve_to_abs_path(self, path: Path | StorePath) -> Path:
|
|
214
|
+
"""
|
|
215
|
+
Return an absolute path, resolving any store paths to within the store
|
|
216
|
+
and resolving other paths like regular `Path.resolve()`.
|
|
217
|
+
"""
|
|
218
|
+
store_path = self.resolve_to_store_path(path)
|
|
219
|
+
if store_path:
|
|
220
|
+
return self.base_dir / store_path
|
|
221
|
+
elif path.is_absolute():
|
|
222
|
+
return path
|
|
223
|
+
else:
|
|
224
|
+
# Unspecified relative paths resolved to cwd.
|
|
225
|
+
# TODO: Consider if such paths might be store paths.
|
|
226
|
+
return path.resolve()
|
|
227
|
+
|
|
198
228
|
def exists(self, store_path: StorePath) -> bool:
|
|
199
229
|
"""
|
|
200
230
|
Check given store path refers to an existing file.
|
|
@@ -204,13 +234,17 @@ class FileStore(Workspace):
|
|
|
204
234
|
@synchronized
|
|
205
235
|
def _pick_filename_for(self, item: Item, *, overwrite: bool = False) -> tuple[str, str | None]:
|
|
206
236
|
"""
|
|
207
|
-
Get a suitable filename for this item.
|
|
208
|
-
|
|
209
|
-
If
|
|
210
|
-
and in this case
|
|
237
|
+
Get a suitable filename for this item. If `overwrite` is true, use the the slugified
|
|
238
|
+
title, regardless of whether it is already in the store.
|
|
239
|
+
If `overwrite` is false, use the slugified title with a suffix to make it unique
|
|
240
|
+
(and in this case also return the old filename for this item).
|
|
211
241
|
"""
|
|
212
242
|
if overwrite:
|
|
213
|
-
log.info(
|
|
243
|
+
log.info(
|
|
244
|
+
"Picked default filename: %s for item: %s",
|
|
245
|
+
item.default_filename(),
|
|
246
|
+
item,
|
|
247
|
+
)
|
|
214
248
|
return item.default_filename(), None
|
|
215
249
|
|
|
216
250
|
slug = item.slug_name()
|
|
@@ -268,14 +302,13 @@ class FileStore(Workspace):
|
|
|
268
302
|
@synchronized
|
|
269
303
|
def store_path_for(
|
|
270
304
|
self, item: Item, *, as_tmp: bool = False, overwrite: bool = False
|
|
271
|
-
) -> tuple[StorePath,
|
|
305
|
+
) -> tuple[StorePath, StorePath | None]:
|
|
272
306
|
"""
|
|
273
307
|
Return the store path for an item. If the item already has a `store_path`, we use that.
|
|
274
308
|
Otherwise we need to find the store path or generate a new one that seems suitable.
|
|
275
309
|
|
|
276
|
-
Returns `store_path,
|
|
277
|
-
|
|
278
|
-
is the previous similarly named item with a different identity (or None there is none).
|
|
310
|
+
Returns `store_path, old_store_path` where `old_store_path` is the previous similarly
|
|
311
|
+
named item with a different identity (or None there is none).
|
|
279
312
|
|
|
280
313
|
If `as_tmp` is true, will return a path from the temporary directory in the store.
|
|
281
314
|
Normally an item is always saved to a unique store path but if `overwrite` is true,
|
|
@@ -284,17 +317,17 @@ class FileStore(Workspace):
|
|
|
284
317
|
item_id = item.item_id()
|
|
285
318
|
old_filename = None
|
|
286
319
|
if as_tmp:
|
|
287
|
-
return self._tmp_path_for(item),
|
|
320
|
+
return self._tmp_path_for(item), None
|
|
288
321
|
elif item.store_path:
|
|
289
|
-
return StorePath(item.store_path),
|
|
322
|
+
return StorePath(item.store_path), None
|
|
290
323
|
elif item_id in self.id_map and self.exists(self.id_map[item_id]):
|
|
291
324
|
# If this item has an identity and we've saved under that id before, use the same store path.
|
|
292
325
|
store_path = self.id_map[item_id]
|
|
293
326
|
log.info(
|
|
294
|
-
"
|
|
327
|
+
"When picking a store path, found an existing item with same id:\n%s",
|
|
295
328
|
fmt_lines([fmt_loc(store_path), item_id]),
|
|
296
329
|
)
|
|
297
|
-
return store_path,
|
|
330
|
+
return store_path, None
|
|
298
331
|
else:
|
|
299
332
|
# We need to pick the path and filename.
|
|
300
333
|
folder_path = folder_for_type(item.type)
|
|
@@ -305,14 +338,14 @@ class FileStore(Workspace):
|
|
|
305
338
|
if old_filename and Path(self.base_dir / folder_path / old_filename).exists():
|
|
306
339
|
old_store_path = StorePath(folder_path / old_filename)
|
|
307
340
|
|
|
308
|
-
return StorePath(store_path),
|
|
341
|
+
return StorePath(store_path), old_store_path
|
|
309
342
|
|
|
310
343
|
def _tmp_path_for(self, item: Item) -> StorePath:
|
|
311
344
|
"""
|
|
312
345
|
Find a path for an item in the tmp directory.
|
|
313
346
|
"""
|
|
314
347
|
if not item.store_path:
|
|
315
|
-
store_path,
|
|
348
|
+
store_path, _old = self.store_path_for(item, as_tmp=False)
|
|
316
349
|
return StorePath(self.dirs.tmp_dir / store_path)
|
|
317
350
|
elif (self.base_dir / item.store_path).is_relative_to(self.dirs.tmp_dir):
|
|
318
351
|
return StorePath(item.store_path)
|
|
@@ -331,14 +364,14 @@ class FileStore(Workspace):
|
|
|
331
364
|
item: Item,
|
|
332
365
|
*,
|
|
333
366
|
overwrite: bool = False,
|
|
334
|
-
skip_dup_names: bool = False,
|
|
335
367
|
as_tmp: bool = False,
|
|
336
368
|
no_format: bool = False,
|
|
337
369
|
no_frontmatter: bool = False,
|
|
338
370
|
) -> StorePath:
|
|
339
371
|
"""
|
|
340
372
|
Save the item. Uses the `store_path` if it's already set or generates a new one.
|
|
341
|
-
Updates `item.store_path`.
|
|
373
|
+
Updates `item.store_path`. An existing file can be added by having the item's
|
|
374
|
+
`external_path` set to a location (inside or outside the store).
|
|
342
375
|
|
|
343
376
|
Unless `no_format` is true, also normalizes body text formatting (for Markdown)
|
|
344
377
|
and updates the item's body to match.
|
|
@@ -348,65 +381,62 @@ class FileStore(Workspace):
|
|
|
348
381
|
If `overwrite` is true, will overwrite a file that has the same path.
|
|
349
382
|
|
|
350
383
|
If `as_tmp` is true, will save the item to a temporary file.
|
|
351
|
-
|
|
352
|
-
If `skip_dup_names` is true, will skip saving if an item if an item with a
|
|
353
|
-
matching path (based on its title) already exists.
|
|
354
384
|
"""
|
|
355
|
-
if overwrite and skip_dup_names:
|
|
356
|
-
raise ValueError("Cannot both overwrite and skip duplicate names.")
|
|
357
385
|
if overwrite and as_tmp:
|
|
358
386
|
raise ValueError("Cannot both overwrite and save to a temporary file.")
|
|
359
387
|
|
|
360
|
-
# If external
|
|
388
|
+
# If external path already exists and is within the workspace, the file was
|
|
389
|
+
# already saved (e.g. by an action that wrote the item directly to the store).
|
|
361
390
|
external_path = item.external_path and Path(item.external_path).resolve()
|
|
362
391
|
if external_path and self._is_in_store(external_path):
|
|
363
|
-
log.
|
|
392
|
+
log.info("Item with external_path already saved: %s", fmt_loc(external_path))
|
|
364
393
|
rel_path = external_path.relative_to(self.base_dir)
|
|
365
|
-
# Indicate this is
|
|
394
|
+
# Indicate this is an item with a store path, not an external path.
|
|
395
|
+
# Keep external_path set so we know body is in that file.
|
|
366
396
|
item.store_path = str(rel_path)
|
|
367
|
-
item.external_path = None
|
|
368
397
|
return StorePath(rel_path)
|
|
369
398
|
else:
|
|
370
399
|
# Otherwise it's still in memory or in a file outside the workspace and we need to save it.
|
|
371
|
-
store_path,
|
|
400
|
+
store_path, old_store_path = self.store_path_for(
|
|
372
401
|
item, as_tmp=as_tmp, overwrite=overwrite
|
|
373
402
|
)
|
|
374
403
|
|
|
375
|
-
if skip_dup_names and found:
|
|
376
|
-
log.message(
|
|
377
|
-
"Skipping save because an item of the same name already exists: %s",
|
|
378
|
-
fmt_loc(store_path),
|
|
379
|
-
)
|
|
380
|
-
item.store_path = str(store_path)
|
|
381
|
-
return store_path
|
|
382
|
-
|
|
383
404
|
full_path = self.base_dir / store_path
|
|
384
405
|
|
|
385
|
-
|
|
406
|
+
supports_frontmatter = item.format and item.format.supports_frontmatter
|
|
407
|
+
log.info(
|
|
408
|
+
"Saving item in format %s (supports_frontmatter=%s) to %s: %s",
|
|
409
|
+
item.format,
|
|
410
|
+
supports_frontmatter,
|
|
411
|
+
fmt_loc(full_path),
|
|
412
|
+
item,
|
|
413
|
+
)
|
|
386
414
|
|
|
387
|
-
# If we're overwriting an existing file, archive it first.
|
|
415
|
+
# If we're overwriting an existing file, archive it first so it is in the archive, not lost.
|
|
388
416
|
if full_path.exists():
|
|
389
417
|
try:
|
|
418
|
+
log.info(
|
|
419
|
+
"Previous file exists so will archive it: %s",
|
|
420
|
+
fmt_loc(store_path),
|
|
421
|
+
)
|
|
390
422
|
self.archive(store_path, quiet=True)
|
|
391
423
|
except Exception as e:
|
|
392
424
|
log.info("Exception archiving existing file: %s", e)
|
|
393
425
|
|
|
394
426
|
# Now save the new item.
|
|
395
427
|
try:
|
|
396
|
-
|
|
397
|
-
# For binary or unknown formats or if we're not adding frontmatter, copy the file exactly.
|
|
428
|
+
# For binary or unknown formats or if we're not adding frontmatter, copy the file.
|
|
398
429
|
if item.external_path and (no_frontmatter or not supports_frontmatter):
|
|
430
|
+
log.info(
|
|
431
|
+
"Path is an external path, so copying: %s -> %s",
|
|
432
|
+
fmt_path(item.external_path),
|
|
433
|
+
fmt_path(full_path),
|
|
434
|
+
)
|
|
399
435
|
copyfile_atomic(item.external_path, full_path, make_parents=True)
|
|
400
436
|
else:
|
|
401
437
|
# Save as a text item with frontmatter.
|
|
402
438
|
if item.external_path:
|
|
403
439
|
item.body = Path(item.external_path).read_text()
|
|
404
|
-
if overwrite and full_path.exists():
|
|
405
|
-
log.info(
|
|
406
|
-
"Overwrite is enabled and a previous file exists so will archive it: %s",
|
|
407
|
-
fmt_loc(store_path),
|
|
408
|
-
)
|
|
409
|
-
self.archive(store_path, quiet=True)
|
|
410
440
|
write_item(item, full_path, normalize=not no_format)
|
|
411
441
|
except OSError as e:
|
|
412
442
|
log.error("Error saving item: %s", e)
|
|
@@ -530,7 +560,7 @@ class FileStore(Workspace):
|
|
|
530
560
|
else:
|
|
531
561
|
# Binary or other files we just copy over as-is, preserving the name.
|
|
532
562
|
# We know the extension is recognized.
|
|
533
|
-
store_path,
|
|
563
|
+
store_path, old_store_path = self.store_path_for(item)
|
|
534
564
|
if self.exists(store_path):
|
|
535
565
|
raise FileExists(f"Resource already in store: {fmt_loc(store_path)}")
|
|
536
566
|
|
|
@@ -675,7 +705,10 @@ class FileStore(Workspace):
|
|
|
675
705
|
for warning in self.warnings:
|
|
676
706
|
log.warning("%s", warning)
|
|
677
707
|
|
|
678
|
-
log.info(
|
|
708
|
+
log.info(
|
|
709
|
+
"File store startup took %s.",
|
|
710
|
+
format_duration(self.end_time - self.start_time),
|
|
711
|
+
)
|
|
679
712
|
# TODO: Log more info like number of items by type.
|
|
680
713
|
return True
|
|
681
714
|
|
|
@@ -28,8 +28,6 @@ def write_item(item: Item, path: Path, normalize: bool = True):
|
|
|
28
28
|
By default normalizes formatting of the body text and updates the item's body.
|
|
29
29
|
"""
|
|
30
30
|
item.validate()
|
|
31
|
-
if item.is_binary:
|
|
32
|
-
raise ValueError(f"Binary items should be external files: {item}")
|
|
33
31
|
if item.format and not item.format.supports_frontmatter:
|
|
34
32
|
raise ValueError(f"Item format `{item.format.value}` does not support frontmatter: {item}")
|
|
35
33
|
|
|
@@ -244,7 +244,7 @@ def _serve_item(
|
|
|
244
244
|
media_type=mime_type,
|
|
245
245
|
)
|
|
246
246
|
else:
|
|
247
|
-
display_title = item.
|
|
247
|
+
display_title = item.pick_title() if item else str(path)
|
|
248
248
|
|
|
249
249
|
# For HEAD requests, return header with mime type only.
|
|
250
250
|
if request.method == "HEAD":
|
kash/model/actions_model.py
CHANGED
|
@@ -22,7 +22,7 @@ from kash.exec_model.shell_model import ShellResult
|
|
|
22
22
|
from kash.llm_utils import LLM, LLMName
|
|
23
23
|
from kash.llm_utils.llm_messages import Message, MessageTemplate
|
|
24
24
|
from kash.model.exec_model import ExecContext
|
|
25
|
-
from kash.model.items_model import UNTITLED, Item, ItemType
|
|
25
|
+
from kash.model.items_model import UNTITLED, Format, Item, ItemType
|
|
26
26
|
from kash.model.operations_model import Operation, Source
|
|
27
27
|
from kash.model.params_model import (
|
|
28
28
|
ALL_COMMON_PARAMS,
|
|
@@ -102,6 +102,10 @@ class ActionResult:
|
|
|
102
102
|
shell_result: ShellResult | None = None
|
|
103
103
|
"""Customize control of how the action's result is displayed in the shell."""
|
|
104
104
|
|
|
105
|
+
def get_by_format(self, format: Format) -> Item:
|
|
106
|
+
"""Convenience method to get an item for actions that return multiple formats."""
|
|
107
|
+
return next(item for item in self.items if item.format == format)
|
|
108
|
+
|
|
105
109
|
def has_hints(self) -> bool:
|
|
106
110
|
return bool(
|
|
107
111
|
self.replaces_input or self.skip_duplicates or self.path_ops or self.shell_result
|
kash/model/items_model.py
CHANGED
|
@@ -219,6 +219,12 @@ class Item:
|
|
|
219
219
|
a text document, PDF or other resource, URL, etc.
|
|
220
220
|
"""
|
|
221
221
|
|
|
222
|
+
# TODO: A few cleanups:
|
|
223
|
+
# - Consider adding aliases and tags. See also Obsidian frontmatter format:
|
|
224
|
+
# https://help.obsidian.md/Editing+and+formatting/Properties#Default%20properties
|
|
225
|
+
# - Can eliminate context here as we now have ExectContext in a contextvar.
|
|
226
|
+
# - Change store_path and external_path to a StorePath and Path instead of a str.
|
|
227
|
+
|
|
222
228
|
type: ItemType
|
|
223
229
|
state: State = State.draft
|
|
224
230
|
title: str | None = None
|
|
@@ -230,17 +236,15 @@ class Item:
|
|
|
230
236
|
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
231
237
|
modified_at: datetime | None = None
|
|
232
238
|
|
|
233
|
-
# TODO: Consider adding aliases and tags. See also Obsidian frontmatter format:
|
|
234
|
-
# https://help.obsidian.md/Editing+and+formatting/Properties#Default%20properties
|
|
235
|
-
|
|
236
239
|
# Content of the item.
|
|
237
240
|
# Text items are in body. Large or binary items may be stored externally.
|
|
241
|
+
# The external_path if present should always hold the current body of the content
|
|
242
|
+
# (and body will not be set). This is necessary for large or binary files.
|
|
238
243
|
body: str | None = None
|
|
239
244
|
external_path: str | None = None
|
|
240
245
|
original_filename: str | None = None
|
|
241
246
|
|
|
242
247
|
# Path to the item in the store, if it has been saved.
|
|
243
|
-
# TODO: Migrate this to StorePath.
|
|
244
248
|
store_path: str | None = None
|
|
245
249
|
|
|
246
250
|
# Optionally, relations to other items, including any time this item is derived from.
|
|
@@ -547,7 +551,7 @@ class Item:
|
|
|
547
551
|
if filename_stem and not prefer_title:
|
|
548
552
|
return slugify_snake(filename_stem)
|
|
549
553
|
else:
|
|
550
|
-
return slugify_snake(self.
|
|
554
|
+
return slugify_snake(self.pick_title(max_len=max_len, add_ops_suffix=True))
|
|
551
555
|
|
|
552
556
|
def default_filename(self) -> str:
|
|
553
557
|
"""
|
|
@@ -560,7 +564,16 @@ class Item:
|
|
|
560
564
|
full_suffix = self.get_full_suffix()
|
|
561
565
|
return join_suffix(slug, full_suffix)
|
|
562
566
|
|
|
563
|
-
def
|
|
567
|
+
def body_heading(self, allowed_tags: tuple[str, ...] = ("h1", "h2")) -> str | None:
|
|
568
|
+
"""
|
|
569
|
+
Get the first heading (by default h1 or h2) from the body text, if present.
|
|
570
|
+
"""
|
|
571
|
+
if self.format in [Format.markdown, Format.md_html]:
|
|
572
|
+
return first_heading(self.body_text(), allowed_tags=allowed_tags)
|
|
573
|
+
# TODO: Support HTML <h1> and <h2> as well.
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
def pick_title(
|
|
564
577
|
self,
|
|
565
578
|
*,
|
|
566
579
|
max_len: int = 100,
|
|
@@ -619,41 +632,20 @@ class Item:
|
|
|
619
632
|
|
|
620
633
|
return final_text
|
|
621
634
|
|
|
622
|
-
def
|
|
623
|
-
"""
|
|
624
|
-
A display title for this item. Same as abbrev_title() but will fall back
|
|
625
|
-
to the filename if it is available.
|
|
626
|
-
"""
|
|
627
|
-
display_title = self.title
|
|
628
|
-
if not display_title and self.store_path:
|
|
629
|
-
display_title = Path(self.store_path).name
|
|
630
|
-
if not display_title:
|
|
631
|
-
display_title = self.abbrev_title()
|
|
632
|
-
return display_title
|
|
633
|
-
|
|
634
|
-
def abbrev_description(self, max_len: int = 1000) -> str:
|
|
635
|
+
def pick_description(self, max_len: int = 1000) -> str:
|
|
635
636
|
"""
|
|
636
637
|
Get or infer description.
|
|
637
638
|
"""
|
|
638
639
|
return abbrev_on_words(html_to_plaintext(self.description or self.body or ""), max_len)
|
|
639
640
|
|
|
640
|
-
def body_heading(self) -> str | None:
|
|
641
|
-
"""
|
|
642
|
-
Get the first h1 or h2 heading from the body text, if present.
|
|
643
|
-
"""
|
|
644
|
-
if self.format in [Format.markdown, Format.md_html]:
|
|
645
|
-
return first_heading(self.body_text(), allowed_tags=("h1", "h2"))
|
|
646
|
-
# TODO: Support HTML <h1> and <h2> as well.
|
|
647
|
-
return None
|
|
648
|
-
|
|
649
641
|
def abbrev_body(self, max_len: int) -> str:
|
|
650
642
|
"""
|
|
651
643
|
Get an abbreviated version of the body text. Must not be a binary Item.
|
|
652
644
|
Abbreviates YAML bodies like {"role": "user", "content": "Hello"} to "user Hello".
|
|
653
645
|
"""
|
|
654
|
-
body_text = self.body_text()
|
|
646
|
+
body_text = abbrev_str(self.body_text(), max_len)
|
|
655
647
|
|
|
656
|
-
# Just for aesthetics especially for titles of chat files.
|
|
648
|
+
# Just for aesthetics, especially for titles of chat files.
|
|
657
649
|
if self.type in [ItemType.chat, ItemType.config] or self.format == Format.yaml:
|
|
658
650
|
try:
|
|
659
651
|
yaml_obj = list(new_yaml().load_all(self.body_text()))
|
|
@@ -662,7 +654,7 @@ class Item:
|
|
|
662
654
|
except Exception as e:
|
|
663
655
|
log.info("Error parsing YAML body: %s", e)
|
|
664
656
|
|
|
665
|
-
return body_text
|
|
657
|
+
return abbrev_str(body_text, max_len)
|
|
666
658
|
|
|
667
659
|
@property
|
|
668
660
|
def has_body(self) -> bool:
|
|
@@ -745,9 +737,14 @@ class Item:
|
|
|
745
737
|
self,
|
|
746
738
|
other: Item | None = None,
|
|
747
739
|
update_timestamp: bool = False,
|
|
740
|
+
clear_fields=(
|
|
741
|
+
"store_path", # Will be set at save time.
|
|
742
|
+
"source", # Should be cleared so the ItemId of a copy is not the same as the original.
|
|
743
|
+
"modified_at",
|
|
744
|
+
),
|
|
748
745
|
**other_updates: Unpack[ItemUpdateOptions],
|
|
749
746
|
) -> dict[str, Any]:
|
|
750
|
-
overrides: dict[str, Any] = {
|
|
747
|
+
overrides: dict[str, Any] = {f: None for f in clear_fields}
|
|
751
748
|
if update_timestamp:
|
|
752
749
|
overrides["created_at"] = datetime.now()
|
|
753
750
|
|
|
@@ -767,8 +764,9 @@ class Item:
|
|
|
767
764
|
self, update_timestamp: bool = True, **other_updates: Unpack[ItemUpdateOptions]
|
|
768
765
|
) -> Item:
|
|
769
766
|
"""
|
|
770
|
-
Copy item with the given field updates. Resets `store_path` to None
|
|
771
|
-
|
|
767
|
+
Copy item with the given field updates. Resets `store_path` and `source` to None
|
|
768
|
+
since those should be set explicitly later. Preserves other fields, including
|
|
769
|
+
the body.
|
|
772
770
|
"""
|
|
773
771
|
new_fields = self._copy_and_update(update_timestamp=update_timestamp, **other_updates)
|
|
774
772
|
return Item(**new_fields)
|
|
@@ -783,9 +781,12 @@ class Item:
|
|
|
783
781
|
|
|
784
782
|
def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
|
|
785
783
|
"""
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
784
|
+
Copy item with the given field updates. Resets `store_path` and `source` to None
|
|
785
|
+
since those should be set explicitly later. Preserves other fields, including
|
|
786
|
+
the body.
|
|
787
|
+
|
|
788
|
+
Same as `new_copy_with` but also updates the `derived_from` relation. If we also
|
|
789
|
+
have an action context, then use the `title_template` to derive a new title.
|
|
789
790
|
"""
|
|
790
791
|
if not self.store_path:
|
|
791
792
|
if self.relations.derived_from:
|
|
@@ -898,7 +899,7 @@ class Item:
|
|
|
898
899
|
elif self.external_path:
|
|
899
900
|
return fmt_loc(self.external_path)
|
|
900
901
|
else:
|
|
901
|
-
return repr(self.
|
|
902
|
+
return repr(self.pick_title())
|
|
902
903
|
|
|
903
904
|
def as_chat_history(self) -> ChatHistory:
|
|
904
905
|
if self.type != ItemType.chat:
|
|
@@ -106,7 +106,9 @@ class Format(Enum):
|
|
|
106
106
|
@property
|
|
107
107
|
def is_simple_text(self) -> bool:
|
|
108
108
|
"""
|
|
109
|
-
Is this plaintext or close to it, like Markdown?
|
|
109
|
+
Is this plaintext or close to it, like Markdown or Markdown with limited HTML?
|
|
110
|
+
"Simple text" should be a format that converts canonically to clean HTML.
|
|
111
|
+
Does not include full-page general HTML.
|
|
110
112
|
"""
|
|
111
113
|
return self in [self.plaintext, self.markdown, self.md_html]
|
|
112
114
|
|
|
@@ -143,17 +145,23 @@ class Format(Enum):
|
|
|
143
145
|
|
|
144
146
|
@property
|
|
145
147
|
def is_markdown(self) -> bool:
|
|
146
|
-
"""Is
|
|
148
|
+
"""Is this pure Markdown? Does not include Markdown mixed with HTML."""
|
|
147
149
|
return self in [self.markdown]
|
|
148
150
|
|
|
149
151
|
@property
|
|
150
152
|
def is_markdown_with_html(self) -> bool:
|
|
151
|
-
"""Is
|
|
153
|
+
"""Is this Markdown mixed with HTML?"""
|
|
152
154
|
return self in [self.md_html]
|
|
153
155
|
|
|
154
156
|
@property
|
|
155
157
|
def is_html(self) -> bool:
|
|
156
|
-
|
|
158
|
+
"""Is this format HTML? Does not include Markdown mixed with HTML."""
|
|
159
|
+
return self in [self.html]
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def is_html_compatible(self) -> bool:
|
|
163
|
+
"""Is this format directly compatible with HTML (any combination of text, markdown, or HTML)?"""
|
|
164
|
+
return self in [self.plaintext, self.markdown, self.md_html, self.html]
|
|
157
165
|
|
|
158
166
|
@property
|
|
159
167
|
def is_data(self) -> bool:
|
|
@@ -168,8 +176,8 @@ class Format(Enum):
|
|
|
168
176
|
"""
|
|
169
177
|
Is this format compatible with frontmatter format metadata?
|
|
170
178
|
PDF and docx unfortunately won't work with frontmatter.
|
|
171
|
-
CSV does to some degree, depending on the tool, and this
|
|
172
|
-
|
|
179
|
+
CSV does to some degree, depending on the tool, and this can be useful so we support it.
|
|
180
|
+
Including JSON here (assuming it's JSON5) for similar reasons.
|
|
173
181
|
"""
|
|
174
182
|
return self in [
|
|
175
183
|
self.url,
|
|
@@ -183,7 +191,7 @@ class Format(Enum):
|
|
|
183
191
|
self.python,
|
|
184
192
|
self.shellscript,
|
|
185
193
|
self.xonsh,
|
|
186
|
-
self.csv,
|
|
194
|
+
self.csv, # Often but not always supported.
|
|
187
195
|
self.log,
|
|
188
196
|
]
|
|
189
197
|
|
|
@@ -9,6 +9,13 @@ from kash.utils.file_utils.file_formats_model import Format, detect_file_format
|
|
|
9
9
|
from kash.utils.rich_custom.ansi_cell_len import ansi_cell_len
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def can_normalize(format: Format) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
True for Markdown (the only format we currently normalize).
|
|
15
|
+
"""
|
|
16
|
+
return format == Format.markdown or format == Format.md_html
|
|
17
|
+
|
|
18
|
+
|
|
12
19
|
def normalize_formatting(
|
|
13
20
|
text: str,
|
|
14
21
|
format: Format | None,
|
kash/web_gen/simple_webpage.py
CHANGED
|
@@ -7,6 +7,7 @@ def simple_webpage_render(
|
|
|
7
7
|
item: Item,
|
|
8
8
|
page_template: str = "simple_webpage.html.jinja",
|
|
9
9
|
add_title_h1: bool = True,
|
|
10
|
+
show_theme_toggle: bool = False,
|
|
10
11
|
) -> str:
|
|
11
12
|
"""
|
|
12
13
|
Generate a simple web page from a single item.
|
|
@@ -15,10 +16,12 @@ def simple_webpage_render(
|
|
|
15
16
|
return render_web_template(
|
|
16
17
|
template_filename=page_template,
|
|
17
18
|
data={
|
|
18
|
-
"title": item.
|
|
19
|
+
"title": item.pick_title(),
|
|
19
20
|
"add_title_h1": add_title_h1,
|
|
20
21
|
"content_html": item.body_as_html(),
|
|
21
22
|
"thumbnail_url": item.thumbnail_url,
|
|
23
|
+
"enable_themes": show_theme_toggle,
|
|
24
|
+
"show_theme_toggle": show_theme_toggle,
|
|
22
25
|
},
|
|
23
26
|
)
|
|
24
27
|
|
kash/web_gen/tabbed_webpage.py
CHANGED
|
@@ -68,14 +68,14 @@ def tabbed_webpage_config(
|
|
|
68
68
|
|
|
69
69
|
tabs = [
|
|
70
70
|
TabInfo(
|
|
71
|
-
label=clean_label(item.
|
|
71
|
+
label=clean_label(item.pick_title()),
|
|
72
72
|
store_path=item.store_path,
|
|
73
73
|
thumbnail_url=get_thumbnail_url(item),
|
|
74
74
|
)
|
|
75
75
|
for item in items
|
|
76
76
|
]
|
|
77
77
|
_fill_in_ids(tabs)
|
|
78
|
-
title = summary_heading([item.
|
|
78
|
+
title = summary_heading([item.pick_title() for item in items])
|
|
79
79
|
config = TabbedWebpage(
|
|
80
80
|
title=title, tabs=tabs, show_tabs=len(tabs) > 1, add_title_h1=add_title_h1
|
|
81
81
|
)
|
|
@@ -100,7 +100,10 @@ def _load_tab_content(config: TabbedWebpage):
|
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def tabbed_webpage_generate(
|
|
103
|
-
config_item: Item,
|
|
103
|
+
config_item: Item,
|
|
104
|
+
page_template: str = "base_webpage.html.jinja",
|
|
105
|
+
add_title_h1: bool = True,
|
|
106
|
+
show_theme_toggle: bool = False,
|
|
104
107
|
) -> str:
|
|
105
108
|
"""
|
|
106
109
|
Generate a web page using the supplied config.
|
|
@@ -121,6 +124,8 @@ def tabbed_webpage_generate(
|
|
|
121
124
|
"title": tabbed_webpage.title,
|
|
122
125
|
"add_title_h1": add_title_h1,
|
|
123
126
|
"content": content,
|
|
127
|
+
"enable_themes": show_theme_toggle,
|
|
128
|
+
"show_theme_toggle": show_theme_toggle,
|
|
124
129
|
},
|
|
125
130
|
)
|
|
126
131
|
|