Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/epy_reader/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

class Config(AppData):
def __init__(self):
super().__init__()
setting_dict = dataclasses.asdict(settings.Settings())
keymap_dict = dataclasses.asdict(settings.CfgDefaultKeymaps())
keymap_builtin_dict = dataclasses.asdict(settings.CfgBuiltinKeymaps())
Expand All @@ -36,6 +37,8 @@ def __init__(self):
# to build help menu text
self.keymap_user_dict = keymap_dict

self.history_file = os.path.join(self.prefix, "readline_history.txt")

@property
def filepath(self) -> str:
return os.path.join(self.prefix, "configuration.json") if self.prefix else os.devnull
Expand Down
177 changes: 174 additions & 3 deletions src/epy_reader/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import datetime
import os
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any, Mapping, Optional, Tuple, Union
from typing import Any, List, Mapping, NamedTuple, Optional, Tuple, Union


class Direction(Enum):
Expand All @@ -25,7 +25,7 @@ class BookMetadata:

@dataclass(frozen=True)
class LibraryItem:
last_read: datetime
last_read: datetime.datetime
filepath: str
title: Optional[str] = None
author: Optional[str] = None
Expand Down Expand Up @@ -230,3 +230,174 @@ def prefix(self) -> Optional[str]:
os.makedirs(prefix, exist_ok=True)

return prefix


# Define the NamedTuple for history entries
class HistoryEntry(NamedTuple):
command: str
timestamp: datetime.datetime


class FileHistory:
"""
Manages input history for curses-based prompts, including loading from
and saving to a file, with timestamps.
"""

def __init__(self, file_name: str):
self.file_path = file_name
self._history: List[HistoryEntry] = []
# Index in _history. When equal to len(_history), it means we're typing
# a new command, not navigating history.
self._current_index: int = -1
# Stores the input typed before the user started navigating history up.
self._current_input_before_history: str = ""
self.MAX_HISTORY_SIZE = 100 # Limit history to prevent excessive memory usage
self.TRUNCATION_PERCENTAGE = 0.10 # 10% of MAX_HISTORY_SIZE to remove

self._load_history()

def _load_history(self):
"""Loads history from the specified file in readline-like format."""
self._history = []
current_timestamp: Optional[datetime.datetime] = None
current_command_lines: List[str] = []

try:
with open(self.file_path, "r") as f:
for line in f:
line = line.strip()
if not line: # Blank line indicates end of a record
if current_timestamp and current_command_lines:
self._history.append(
HistoryEntry(
command="".join(
current_command_lines
), # Join lines if multi-line input is ever supported
timestamp=current_timestamp,
)
)
current_timestamp = None
current_command_lines = []
elif line.startswith("#"):
try:
# Attempt to parse the timestamp
# Example format: #1716709841 (Unix timestamp)
timestamp_str = line[1:].strip()
current_timestamp = datetime.datetime.fromtimestamp(
int(timestamp_str),
tz=datetime.timezone.utc, # Assuming UTC for timestamp storage
)
except (ValueError, TypeError):
# Ignore malformed timestamp lines
current_timestamp = None
elif line.startswith("+") and current_timestamp:
# Only add command if we have a valid timestamp preceding it
current_command_lines.append(line[1:].strip())
# else: Ignore lines that don't match the format or have no timestamp yet

# Add any remaining command if the file doesn't end with a blank line
if current_timestamp and current_command_lines:
self._history.append(
HistoryEntry(
command="".join(current_command_lines), timestamp=current_timestamp
)
)

# Apply truncation on load if history is too large
if len(self._history) > self.MAX_HISTORY_SIZE:
num_to_remove = int(self.MAX_HISTORY_SIZE * self.TRUNCATION_PERCENTAGE)
self._history = self._history[num_to_remove:]

except FileNotFoundError:
self._history = []
except Exception as e:
# Log this in a real application instead of print
print(f"Warning: Could not load history from {self.file_path}: {e}")
self._history = [] # Clear history if there's a parsing error
self.reset_index() # Initialize index to point to the end of history

def save_history(self):
"""Saves current history to the specified file in readline-like format."""
try:
with open(self.file_path, "w") as f:
for entry in self._history:
# Write timestamp line
f.write(f"#{int(entry.timestamp.timestamp())}\n")
# Write command line(s)
# For now, we assume single-line commands
f.write(f"+{entry.command}\n")
# Write blank line separator
f.write("\n")
except IOError as e:
print(f"Warning: Could not save command history to {self.file_path}: {e}")

def add_command(self, command: str):
"""Adds a command to the history, preventing duplicates at the end, with a timestamp.
Removes a batch of oldest items if MAX_HISTORY_SIZE is exceeded.
"""
if command and (not self._history or self._history[-1].command != command):
self._history.append(
HistoryEntry(
command=command,
timestamp=datetime.datetime.now(
datetime.timezone.utc
), # Store current UTC time
)
)

# Check if history size exceeds the limit
if len(self._history) > self.MAX_HISTORY_SIZE:
num_to_remove = int(self.MAX_HISTORY_SIZE * self.TRUNCATION_PERCENTAGE)
# Ensure we remove at least 1 item if we're over the limit
if num_to_remove == 0 and self.MAX_HISTORY_SIZE > 0:
num_to_remove = 1

self._history = self._history[num_to_remove:]
# After removal, the history index might become invalid if it was
# pointing to an item that was removed. Reset it.
self.reset_index()

self.reset_index() # Always reset index after adding a new command (or if history wasn't full)

def navigate_up(self, current_input: str) -> Optional[str]:
"""
Navigates up through history. Stores current_input if starting navigation.
Returns the command string of the history item or None if at the top.
"""
if not self._history:
return None

if self._current_index == len(self._history):
# Store current input only if we're starting to navigate history
self._current_input_before_history = current_input

if self._current_index > 0:
self._current_index -= 1
return self._history[self._current_index].command
return None # Already at the oldest entry

def navigate_down(self) -> Optional[str]:
"""
Navigates down through history. Returns the command string of the history item or the
stored current input if at the newest entry. Returns empty string if no history.
"""
if not self._history:
return "" # Return empty string for consistency with new input

if self._current_index < len(self._history) - 1:
self._current_index += 1
return self._history[self._current_index].command
elif self._current_index == len(self._history) - 1:
# At the newest history item, going down means returning to the current input
self.reset_index()
return self._current_input_before_history
# If _current_index is already past the last history item (e.g., after new command)
# or if history is empty, return stored current input
self.reset_index()
return self._current_input_before_history

def reset_index(self):
"""Resets the history index to point after the last history item, for new input."""
self._current_index = len(self._history)
self._current_input_before_history = "" # Clear stored input on reset
Loading