* feat(i18n): complete 100% internationalization and fix Next.js 15 compatibility
* feat(i18n): complete 100% internationalization coverage
* chore(test): finalize component tests and project cleanup
* test(logic): add unit tests for useModalManager hook
* fix(test): resolve timeout in AppSidebar tests by mocking TooltipProvider
* feat(i18n): comprehensive i18n audit, fixes for hardcoded strings, and complete zh-TW support
* fix(i18n): resolve TypeScript warnings and improve translation hook stability
- Remove unused useTranslation import from ConnectionGuard
- Add ref-based checking state to prevent dependency cycles
- Fix useTranslation hook to return empty string for undefined translations
- Add comment for backward compatibility on ExtractedReference interface
- Ensure .replace() string methods work safely with nested translation keys
* feat(i18n): complete internationalization implementation with Docker deployment
- Add LanguageLoadingOverlay component for smooth language transitions
- Update all translation files (en-US, zh-CN, zh-TW) with improved terminology
- Optimize Docker configuration for better performance
- Update version check and config handling for i18n support
- Fix route handling for language-specific content
- Add comprehensive task documentation
* fix(i18n): resolve localization errors, duplicates, and type issues
* chore(i18n): finalize 100% internationalization coverage
* chore(test): supplement i18n test cases and cleanup redundant files
* fix(test): resolve lint type errors and finalize delivery documents
* feat(i18n): finalize full internationalization and zh-TW localization
* fix(frontend): add missing devDependency and fix build tsconfig
* feat(ui): enhance sidebar hover effects with better visual feedback
* fix(frontend): resolve accessibility, i18n, and lint issues
- fix: add missing id, name, autocomplete attributes to dialog inputs
- fix: add aria labels and DialogDescription for accessibility
- fix: resolve uncontrolled component warning in SettingsForm
- fix: correct duplicate 'Traditional Chinese' label in zh-TW locale
- feat: add i18n support for podcast template names
- chore: fix lint errors in Dialogs
* fix: address all 21 PR feedback items from cubic-dev-ai bot
Configuration:
- Remove ignoreDuringBuilds flags from next.config.ts
Testing:
- Fix AppSidebar.test.tsx regex pattern and add missing assertion
Logic:
- Fix ConnectionGuard.tsx re-entry prevention logic
Internationalization (I18n) - Translations:
- Add missing keys: notebooks.archived, common.note/insight, accessibility keys
- Add specific keys: sources.allSourcesDescShort, transformations.selectModel
- Add singular/plural keys: podcasts.usedByCount_one/other, common.note/notes
- Add common.created/updated with {time} placeholder
Internationalization (I18n) - Usage:
- SourcesPage: use allSourcesDescShort instead of string splitting
- TransformationPlayground: use navigation.transformation and selectModel
- CommandPalette: use dedicated keys instead of string concatenation
- GeneratePodcastDialog: fix zh-TW date locale handling
- NotebookHeader: correctly interpolate {time} placeholder
- TransformationCard: use common.description instead of undefined key
- ChatPanel/SpeakerProfilesPanel: implement proper pluralization
- SystemInfo: correctly interpolate {version} placeholder
- LanguageLoadingOverlay: use t.common.loading instead of hardcoded string
- MessageActions: use specific error key cannotSaveNoteNoNotebook
Other:
- Fix SessionManager.tsx exhaustive-deps warning
* fix: remove duplicate locale keys and add missing zh-CN translations
- en-US: remove duplicate loading key (line 59) and addNew key (sources)
- zh-CN: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-CN: remove duplicate accessibility.searchNotebooks key
- zh-CN: remove duplicate sources.addNew key
- zh-CN: remove duplicate navigation.transformation key
- zh-CN: add missing usedByCount_one and usedByCount_other keys in podcasts
- zh-TW: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-TW: remove duplicate accessibility.searchNotebooks key
- zh-TW: remove duplicate sources.addNew key
* docs: remove info.md
* fix: remove duplicate notebook keys and unused ts-expect-error
- zh-CN: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- zh-TW: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- GeneratePodcastDialog: remove unused @ts-expect-error directive
* fix(a11y): fix unassociated labels in search page
- Replace <Label> with role='group' + aria-labelledby for search type section
- Replace <Label> with role='group' + aria-labelledby for search in section
- Follows WAI-ARIA best practices for labeling form field groups
* fix(a11y): fix unassociated labels across multiple components
- search/page.tsx: use role='group' + aria-labelledby for search type and search in sections
- RebuildEmbeddings.tsx: use role='group' + aria-labelledby for include checkboxes
- TransformationPlayground.tsx: replace Label with span for non-form output label
* chore: revert to npm stack and ensure i18n compatibility
* chore: polish zh-TW translations for better idiomatic usage
* fix: resolve linter errors (ruff import sort, mypy config duplicate)
* style: apply ruff formatting
* fix: finalize upstream compliance (Dockerfile.single, i18n hooks, docker-compose)
* style: polish strings, fix timeout cleanup, and improve test mocks
* fix: use relative imports in test setup to resolve IDE path errors
* perf(docker): optimize build speed by removing apt-get upgrade and build tools
- Remove apt-get upgrade from both builder and runtime stages (saves 10-15 min each)
- Remove gcc/g++/make/git from builder (uv downloads pre-built wheels)
- Add --no-install-recommends to minimize package footprint
- Keep npm mirror (npmmirror.com) for faster frontend deps
- Add npm registry config for reliable China network access
Also includes:
- fix(a11y): add missing labels and aria attributes to form fields
- fix(i18n): add 2s safety timeout to LanguageLoadingOverlay
- fix(i18n): add robustness checks to use-translation proxy
Build time reduced from 2+ hours to ~34 minutes (~70% improvement)
* fix(a11y): resolve 16 form field accessibility warnings in notebook and podcast pages
* fix(a11y): resolve 4 button and 1 select field accessibility warnings in models page
* fix(a11y): resolve redundant attributes and residual warnings in transformations and podcast forms
* fix(i18n): deep fix for language switch hang using proxy protection and safer access
* fix(a11y): add name attributes to ModelSelector, TransformationPlayground, and SourceDetailContent
* fix: add missing Label import to SourceDetailContent
* fix(i18n): use native react-i18next in LanguageLoadingOverlay to prevent hang during language switch
* fix(i18n): rewrite use-translation Proxy with strict depth limit and expanded blocked props to prevent language switch hang
* fix: add type assertion to fix TypeScript comparison error
* fix(i18n): disable useSuspense to prevent thread hang during language resource loading
* fix(i18n): add infinite loop detection circuit breaker to useTranslation hook
* fix(i18n): update traditional chinese label to native script in en-US
* feat: add new localization strings for notebook and note management.
* fix: resolve config priority, docker build deps, and ui glitches
* refactor: improve ui details and test coverage based on feedback
* refactor: improve ui details (version check/lang toggle) and test coverage
* fix: polish language matching and test cleanup
* fix(test): update mocks to resolve timeouts and proxy errors
* fix(frontend): restore tsconfig.json structure and enable IDE support for tests
* fix: address PR review findings and resolve CI OIDC failure
* fix: merge exception headers in custom handler
* fix: comprehensive PR review remediations and async performance fixes
* refactor: address all PR #371 review feedback
- Docker: consolidate SURREAL_URL to docker.env, add single-container override
- Security: restore apt-get upgrade in Dockerfile and Dockerfile.single
- Create centralized getDateLocale helper (lib/utils/date-locale.ts)
- Refactor 7 files to use getDateLocale helper
- Revert config/route.ts to origin/main version
- Move test files to co-located pattern (3 files)
- Remove local useTranslation mock from ConfirmDialog.test.tsx
- Simplify use-version-check to single useEffect pattern
- Fix test import paths after moving to co-located pattern
* fix: add jest-dom types for test files
* fix: address remaining review issues
- Add apt-get upgrade -y to Dockerfile.single backend-builder stage
- Refactor ChatColumn.test.tsx: use 'as unknown as ReturnType<typeof hook>' instead of 'as any'
- Use toBeInTheDocument() assertions instead of toBeDefined()
343 lines
13 KiB
Python
343 lines
13 KiB
Python
from datetime import datetime
|
|
from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union, cast
|
|
|
|
from loguru import logger
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
ValidationError,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from open_notebook.database.repository import (
|
|
ensure_record_id,
|
|
repo_create,
|
|
repo_delete,
|
|
repo_query,
|
|
repo_relate,
|
|
repo_update,
|
|
repo_upsert,
|
|
)
|
|
from open_notebook.exceptions import (
|
|
DatabaseOperationError,
|
|
InvalidInputError,
|
|
NotFoundError,
|
|
)
|
|
|
|
T = TypeVar("T", bound="ObjectModel")
|
|
|
|
|
|
class ObjectModel(BaseModel):
|
|
id: Optional[str] = None
|
|
table_name: ClassVar[str] = ""
|
|
nullable_fields: ClassVar[set[str]] = set() # Fields that can be saved as None
|
|
created: Optional[datetime] = None
|
|
updated: Optional[datetime] = None
|
|
|
|
@classmethod
|
|
async def get_all(cls: Type[T], order_by=None) -> List[T]:
|
|
try:
|
|
# If called from a specific subclass, use its table_name
|
|
if cls.table_name:
|
|
target_class = cls
|
|
table_name = cls.table_name
|
|
else:
|
|
# This path is taken if called directly from ObjectModel
|
|
raise InvalidInputError(
|
|
"get_all() must be called from a specific model class"
|
|
)
|
|
if order_by:
|
|
query = f"SELECT * FROM {table_name} ORDER BY {order_by}"
|
|
else:
|
|
query = f"SELECT * FROM {table_name}"
|
|
|
|
result = await repo_query(query)
|
|
objects = []
|
|
for obj in result:
|
|
try:
|
|
objects.append(target_class(**obj))
|
|
except Exception as e:
|
|
logger.critical(f"Error creating object: {str(e)}")
|
|
|
|
return objects
|
|
except Exception as e:
|
|
logger.error(f"Error fetching all {cls.table_name}: {str(e)}")
|
|
logger.exception(e)
|
|
raise DatabaseOperationError(e)
|
|
|
|
@classmethod
|
|
async def get(cls: Type[T], id: str) -> T:
|
|
if not id:
|
|
raise InvalidInputError("ID cannot be empty")
|
|
try:
|
|
# Get the table name from the ID (everything before the first colon)
|
|
table_name = id.split(":")[0] if ":" in id else id
|
|
|
|
# If we're calling from a specific subclass and IDs match, use that class
|
|
if cls.table_name and cls.table_name == table_name:
|
|
target_class: Type[T] = cls
|
|
else:
|
|
# Otherwise, find the appropriate subclass based on table_name
|
|
found_class = cls._get_class_by_table_name(table_name)
|
|
if not found_class:
|
|
raise InvalidInputError(f"No class found for table {table_name}")
|
|
target_class = cast(Type[T], found_class)
|
|
|
|
result = await repo_query("SELECT * FROM $id", {"id": ensure_record_id(id)})
|
|
if result:
|
|
return target_class(**result[0])
|
|
else:
|
|
raise NotFoundError(f"{table_name} with id {id} not found")
|
|
except Exception as e:
|
|
logger.error(f"Error fetching object with id {id}: {str(e)}")
|
|
logger.exception(e)
|
|
raise NotFoundError(f"Object with id {id} not found - {str(e)}")
|
|
|
|
@classmethod
|
|
def _get_class_by_table_name(cls, table_name: str) -> Optional[Type["ObjectModel"]]:
|
|
"""Find the appropriate subclass based on table_name."""
|
|
|
|
def get_all_subclasses(c: Type["ObjectModel"]) -> List[Type["ObjectModel"]]:
|
|
all_subclasses: List[Type["ObjectModel"]] = []
|
|
for subclass in c.__subclasses__():
|
|
all_subclasses.append(subclass)
|
|
all_subclasses.extend(get_all_subclasses(subclass))
|
|
return all_subclasses
|
|
|
|
for subclass in get_all_subclasses(ObjectModel):
|
|
if hasattr(subclass, "table_name") and subclass.table_name == table_name:
|
|
return subclass
|
|
return None
|
|
|
|
def needs_embedding(self) -> bool:
|
|
return False
|
|
|
|
def get_embedding_content(self) -> Optional[str]:
|
|
return None
|
|
|
|
async def save(self) -> None:
|
|
from open_notebook.ai.models import model_manager
|
|
|
|
try:
|
|
self.model_validate(self.model_dump(), strict=True)
|
|
data = self._prepare_save_data()
|
|
data["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
if self.needs_embedding():
|
|
embedding_content = self.get_embedding_content()
|
|
if embedding_content:
|
|
EMBEDDING_MODEL = await model_manager.get_embedding_model()
|
|
if not EMBEDDING_MODEL:
|
|
logger.warning(
|
|
"No embedding model found. Content will not be searchable."
|
|
)
|
|
data["embedding"] = (
|
|
(await EMBEDDING_MODEL.aembed([embedding_content]))[0]
|
|
if EMBEDDING_MODEL
|
|
else []
|
|
)
|
|
|
|
repo_result: Union[List[Dict[str, Any]], Dict[str, Any]]
|
|
if self.id is None:
|
|
data["created"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
repo_result = await repo_create(self.__class__.table_name, data)
|
|
else:
|
|
data["created"] = (
|
|
self.created.strftime("%Y-%m-%d %H:%M:%S")
|
|
if isinstance(self.created, datetime)
|
|
else self.created
|
|
)
|
|
logger.debug(f"Updating record with id {self.id}")
|
|
repo_result = await repo_update(
|
|
self.__class__.table_name, self.id, data
|
|
)
|
|
# Update the current instance with the result
|
|
# repo_result is a list of dictionaries
|
|
result_list: List[Dict[str, Any]] = (
|
|
repo_result if isinstance(repo_result, list) else [repo_result]
|
|
)
|
|
for key, value in result_list[0].items():
|
|
if hasattr(self, key):
|
|
if isinstance(getattr(self, key), BaseModel):
|
|
setattr(self, key, type(getattr(self, key))(**value))
|
|
else:
|
|
setattr(self, key, value)
|
|
|
|
except ValidationError as e:
|
|
logger.error(f"Validation failed: {e}")
|
|
raise
|
|
except RuntimeError:
|
|
# Transaction conflicts should propagate for retry
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error saving record: {e}")
|
|
raise DatabaseOperationError(e)
|
|
|
|
def _prepare_save_data(self) -> Dict[str, Any]:
|
|
data = self.model_dump()
|
|
return {
|
|
key: value
|
|
for key, value in data.items()
|
|
if value is not None or key in self.__class__.nullable_fields
|
|
}
|
|
|
|
async def delete(self) -> bool:
|
|
if self.id is None:
|
|
raise InvalidInputError("Cannot delete object without an ID")
|
|
try:
|
|
logger.debug(f"Deleting record with id {self.id}")
|
|
return await repo_delete(self.id)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error deleting {self.__class__.table_name} with id {self.id}: {str(e)}"
|
|
)
|
|
raise DatabaseOperationError(
|
|
f"Failed to delete {self.__class__.table_name}"
|
|
)
|
|
|
|
async def relate(
|
|
self, relationship: str, target_id: str, data: Optional[Dict] = {}
|
|
) -> Any:
|
|
if not relationship or not target_id or not self.id:
|
|
raise InvalidInputError("Relationship and target ID must be provided")
|
|
try:
|
|
return await repo_relate(
|
|
source=self.id, relationship=relationship, target=target_id, data=data
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error creating relationship: {str(e)}")
|
|
logger.exception(e)
|
|
raise DatabaseOperationError(e)
|
|
|
|
@field_validator("created", "updated", mode="before")
|
|
@classmethod
|
|
def parse_datetime(cls, value):
|
|
if isinstance(value, str):
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
return value
|
|
|
|
|
|
class RecordModel(BaseModel):
|
|
model_config = ConfigDict(
|
|
validate_assignment=True,
|
|
arbitrary_types_allowed=True,
|
|
extra="allow",
|
|
from_attributes=True,
|
|
defer_build=True,
|
|
)
|
|
|
|
record_id: ClassVar[str]
|
|
auto_save: ClassVar[bool] = (
|
|
False # Default to False, can be overridden in subclasses
|
|
)
|
|
_instances: ClassVar[Dict[str, "RecordModel"]] = {} # Store instances by record_id
|
|
|
|
def __new__(cls, **kwargs):
|
|
# If an instance already exists for this record_id, return it
|
|
if cls.record_id in cls._instances:
|
|
instance = cls._instances[cls.record_id]
|
|
# Update instance with any new kwargs if provided
|
|
if kwargs:
|
|
for key, value in kwargs.items():
|
|
setattr(instance, key, value)
|
|
return instance
|
|
|
|
# If no instance exists, create a new one
|
|
instance = super().__new__(cls)
|
|
cls._instances[cls.record_id] = instance
|
|
return instance
|
|
|
|
def __init__(self, **kwargs):
|
|
# Only initialize if this is a new instance
|
|
if not hasattr(self, "_initialized"):
|
|
object.__setattr__(self, "__dict__", {})
|
|
|
|
# For RecordModel, we need to handle async initialization differently
|
|
# Initialize with provided kwargs only for now
|
|
super().__init__(**kwargs)
|
|
|
|
# Mark as initialized but not loaded from DB yet
|
|
object.__setattr__(self, "_initialized", True)
|
|
object.__setattr__(self, "_db_loaded", False)
|
|
|
|
async def _load_from_db(self):
|
|
"""Load data from database if not already loaded"""
|
|
if not getattr(self, "_db_loaded", False):
|
|
result = await repo_query(
|
|
"SELECT * FROM ONLY $record_id",
|
|
{"record_id": ensure_record_id(self.record_id)},
|
|
)
|
|
|
|
# Handle case where record doesn't exist yet
|
|
if result:
|
|
if isinstance(result, list) and len(result) > 0:
|
|
# Standard list response
|
|
row = result[0]
|
|
if isinstance(row, dict):
|
|
for key, value in row.items():
|
|
if hasattr(self, key):
|
|
object.__setattr__(self, key, value)
|
|
elif isinstance(result, dict):
|
|
# Direct dict response
|
|
for key, value in result.items():
|
|
if hasattr(self, key):
|
|
object.__setattr__(self, key, value)
|
|
|
|
object.__setattr__(self, "_db_loaded", True)
|
|
|
|
@classmethod
|
|
async def get_instance(cls) -> "RecordModel":
|
|
"""Get or create the singleton instance and load from DB"""
|
|
instance = cls()
|
|
await instance._load_from_db()
|
|
return instance
|
|
|
|
@model_validator(mode="after")
|
|
def auto_save_validator(self):
|
|
if self.__class__.auto_save:
|
|
# Auto-save can't work with async - log warning
|
|
logger.warning(
|
|
f"Auto-save is enabled for {self.__class__.__name__} but update() is now async. Call await instance.update() manually."
|
|
)
|
|
return self
|
|
|
|
async def update(self):
|
|
# Get all non-ClassVar fields and their values
|
|
data = {
|
|
field_name: getattr(self, field_name)
|
|
for field_name, field_info in self.model_fields.items()
|
|
if not str(field_info.annotation).startswith("typing.ClassVar")
|
|
}
|
|
|
|
await repo_upsert(
|
|
self.__class__.table_name
|
|
if hasattr(self.__class__, "table_name")
|
|
else "record",
|
|
self.record_id,
|
|
data,
|
|
)
|
|
|
|
result = await repo_query(
|
|
"SELECT * FROM $record_id", {"record_id": ensure_record_id(self.record_id)}
|
|
)
|
|
if result:
|
|
for key, value in result[0].items():
|
|
if hasattr(self, key):
|
|
object.__setattr__(
|
|
self, key, value
|
|
) # Use object.__setattr__ to avoid triggering validation again
|
|
|
|
return self
|
|
|
|
@classmethod
|
|
def clear_instance(cls):
|
|
"""Clear the singleton instance (useful for testing)"""
|
|
if cls.record_id in cls._instances:
|
|
del cls._instances[cls.record_id]
|
|
|
|
async def patch(self, model_dict: dict):
|
|
"""Update model attributes from dictionary and save"""
|
|
for key, value in model_dict.items():
|
|
setattr(self, key, value)
|
|
await self.update()
|