import logging from datetime import date, datetime, timezone from sqlalchemy import func, or_, select, text from fabledassistant.models import async_session from fabledassistant.models.note import Note, TaskPriority, TaskStatus logger = logging.getLogger(__name__) async def create_note( user_id: int, title: str = "", body: str = "", tags: list[str] | None = None, parent_id: int | None = None, status: str | None = None, priority: str | None = None, due_date: date | None = None, ) -> Note: async with async_session() as session: note = Note( user_id=user_id, title=title, body=body, tags=tags or [], parent_id=parent_id, status=status, priority=priority, due_date=due_date, ) session.add(note) await session.commit() await session.refresh(note) return note async def get_note(user_id: int, note_id: int) -> Note | None: async with async_session() as session: result = await session.execute( select(Note).where(Note.id == note_id, Note.user_id == user_id) ) return result.scalars().first() async def list_notes( user_id: int, q: str | None = None, tags: list[str] | None = None, is_task: bool | None = None, status: str | None = None, priority: str | None = None, sort: str = "updated_at", order: str = "desc", limit: int = 50, offset: int = 0, ) -> tuple[list[Note], int]: async with async_session() as session: query = select(Note).where(Note.user_id == user_id) count_query = select(func.count(Note.id)).where(Note.user_id == user_id) # Filter by task vs note if is_task is True: query = query.where(Note.status.isnot(None)) count_query = count_query.where(Note.status.isnot(None)) elif is_task is False: query = query.where(Note.status.is_(None)) count_query = count_query.where(Note.status.is_(None)) if q: terms = q.split() for term in terms: escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") pattern = f"%{escaped_term}%" term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern)) query = query.where(term_filter) count_query = count_query.where(term_filter) if tags: for i, tag in enumerate(tags): param_tag = f"tag_{i}" param_prefix = f"tag_prefix_{i}" tag_filter = text( f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t" f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})" ).bindparams(**{param_tag: tag, param_prefix: tag + "/%"}) query = query.where(tag_filter) count_query = count_query.where(tag_filter) if status: query = query.where(Note.status == status) count_query = count_query.where(Note.status == status) if priority: query = query.where(Note.priority == priority) count_query = count_query.where(Note.priority == priority) sort_col = getattr(Note, sort, Note.updated_at) if order == "asc": query = query.order_by(sort_col.asc()) else: query = query.order_by(sort_col.desc()) query = query.limit(limit).offset(offset) total = await session.scalar(count_query) or 0 result = await session.execute(query) notes = list(result.scalars().all()) return notes, total async def get_note_by_title(user_id: int, title: str) -> Note | None: async with async_session() as session: result = await session.execute( select(Note).where( Note.user_id == user_id, func.lower(Note.title) == func.lower(title.strip()), ).limit(1) ) return result.scalars().first() async def get_or_create_note_by_title(user_id: int, title: str) -> Note: title = title.strip() note = await get_note_by_title(user_id, title) if note: return note return await create_note(user_id, title=title) async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None: async with async_session() as session: result = await session.execute( select(Note).where(Note.id == note_id, Note.user_id == user_id) ) note = result.scalars().first() if note is None: return None for key, value in fields.items(): if not hasattr(note, key): continue if key == "status" and isinstance(value, str): value = TaskStatus(value).value elif key == "priority" and isinstance(value, str): value = TaskPriority(value).value setattr(note, key, value) note.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(note) return note async def delete_note(user_id: int, note_id: int) -> bool: async with async_session() as session: result = await session.execute( select(Note).where(Note.id == note_id, Note.user_id == user_id) ) note = result.scalars().first() if note is None: return False await session.delete(note) await session.commit() return True async def get_all_tags(user_id: int, q: str | None = None) -> list[str]: async with async_session() as session: result = await session.execute( text( "SELECT DISTINCT unnest(tags) AS tag FROM notes" " WHERE tags != '{}' AND user_id = :user_id" ).bindparams(user_id=user_id) ) all_tags = [row[0] for row in result.fetchall()] if q: q_lower = q.lower() all_tags = [t for t in all_tags if q_lower in t.lower()] return sorted(all_tags) async def convert_note_to_task(user_id: int, note_id: int) -> Note: async with async_session() as session: result = await session.execute( select(Note).where(Note.id == note_id, Note.user_id == user_id) ) note = result.scalars().first() if note is None: logger.warning("convert_note_to_task: note %d not found", note_id) raise ValueError("Note not found") note.status = TaskStatus.todo.value note.priority = TaskPriority.none.value note.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(note) logger.info("Converted note %d to task", note_id) return note async def convert_task_to_note(user_id: int, note_id: int) -> Note: async with async_session() as session: result = await session.execute( select(Note).where(Note.id == note_id, Note.user_id == user_id) ) note = result.scalars().first() if note is None: logger.warning("convert_task_to_note: note %d not found", note_id) raise ValueError("Note not found") note.status = None note.priority = None note.due_date = None note.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(note) logger.info("Converted task %d to note", note_id) return note async def search_notes_for_context( user_id: int, keywords: list[str], exclude_ids: set[int] | None = None, limit: int = 3, ) -> list[Note]: """Search notes by keywords with OR logic. Optimized for context building — no count query.""" async with async_session() as session: keyword_filters = [] for kw in keywords: escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") pattern = f"%{escaped}%" keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern))) query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters)) if exclude_ids: query = query.where(Note.id.notin_(exclude_ids)) query = query.order_by(Note.updated_at.desc()).limit(limit) result = await session.execute(query) return list(result.scalars().all()) async def get_backlinks(user_id: int, note_id: int) -> list[dict]: note = await get_note(user_id, note_id) if note is None: return [] title = note.title if not title: return [] async with async_session() as session: escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") pattern = f"%[[{escaped}]]%" pattern_alias = f"%[[{escaped}|%" results = await session.execute( select(Note.id, Note.title, Note.status).where( Note.user_id == user_id, Note.id != note_id, or_(Note.body.like(pattern), Note.body.like(pattern_alias)), ) ) backlinks: list[dict] = [] for row in results.fetchall(): link_type = "task" if row[2] is not None else "note" backlinks.append({"type": link_type, "id": row[0], "title": row[1]}) return backlinks