dfdfdf
This commit is contained in:
@@ -388,25 +388,55 @@ class HydrusNetwork:
|
||||
results[file_hash] = self._post("/add_url/associate_url", data=body)
|
||||
return {"batched": results}
|
||||
|
||||
def set_notes(self, file_hashes: Union[str, Iterable[str]], notes: dict[str, str], service_name: str) -> dict[str, Any]:
|
||||
def set_notes(
|
||||
self,
|
||||
file_hash: str,
|
||||
notes: dict[str, str],
|
||||
*,
|
||||
merge_cleverly: bool = False,
|
||||
extend_existing_note_if_possible: bool = True,
|
||||
conflict_resolution: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""Add or update notes associated with a file.
|
||||
|
||||
Hydrus Client API: POST /add_notes/set_notes
|
||||
Required JSON args: {"hash": <sha256 hex>, "notes": {name: text}}
|
||||
"""
|
||||
if not notes:
|
||||
raise ValueError("notes mapping must not be empty")
|
||||
hashes = self._ensure_hashes(file_hashes)
|
||||
body = {"hashes": hashes, "service_names_to_notes": {service_name: notes}}
|
||||
|
||||
file_hash = str(file_hash or "").strip().lower()
|
||||
if not file_hash:
|
||||
raise ValueError("file_hash must not be empty")
|
||||
|
||||
body: dict[str, Any] = {"hash": file_hash, "notes": notes}
|
||||
|
||||
if merge_cleverly:
|
||||
body["merge_cleverly"] = True
|
||||
body["extend_existing_note_if_possible"] = bool(extend_existing_note_if_possible)
|
||||
body["conflict_resolution"] = int(conflict_resolution)
|
||||
return self._post("/add_notes/set_notes", data=body)
|
||||
|
||||
def delete_notes(
|
||||
self,
|
||||
file_hashes: Union[str, Iterable[str]],
|
||||
file_hash: str,
|
||||
note_names: Sequence[str],
|
||||
service_name: str,
|
||||
) -> dict[str, Any]:
|
||||
names = [name for name in note_names if name]
|
||||
"""Delete notes associated with a file.
|
||||
|
||||
Hydrus Client API: POST /add_notes/delete_notes
|
||||
Required JSON args: {"hash": <sha256 hex>, "note_names": [..]}
|
||||
"""
|
||||
names = [str(name) for name in note_names if str(name or "").strip()]
|
||||
if not names:
|
||||
raise ValueError("note_names must not be empty")
|
||||
hashes = self._ensure_hashes(file_hashes)
|
||||
body = {"hashes": hashes, "service_names_to_deleted_note_names": {service_name: names}}
|
||||
return self._post("/add_notes/set_notes", data=body)
|
||||
|
||||
file_hash = str(file_hash or "").strip().lower()
|
||||
if not file_hash:
|
||||
raise ValueError("file_hash must not be empty")
|
||||
|
||||
body = {"hash": file_hash, "note_names": names}
|
||||
return self._post("/add_notes/delete_notes", data=body)
|
||||
|
||||
def get_file_relationships(self, file_hash: str) -> dict[str, Any]:
|
||||
query = {"hash": file_hash}
|
||||
|
||||
@@ -804,7 +804,7 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
|
||||
def _register_unlock_link():
|
||||
"""Register unlock-link command with cmdlet registry if available."""
|
||||
try:
|
||||
from cmdlets import register
|
||||
from cmdlet import register
|
||||
|
||||
@register(["unlock-link"])
|
||||
def unlock_link_wrapper(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
@@ -821,7 +821,7 @@ def _register_unlock_link():
|
||||
|
||||
return unlock_link_wrapper
|
||||
except ImportError:
|
||||
# If cmdlets module not available, just return None
|
||||
# If cmdlet module not available, just return None
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ This module provides low-level functions for interacting with Archive.org:
|
||||
- Image downloading and deobfuscation
|
||||
- PDF creation with metadata
|
||||
|
||||
Used by unified_book_downloader.py for the borrowing workflow.
|
||||
Used by Provider/openlibrary.py for the borrowing workflow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
125
API/folder.py
125
API/folder.py
@@ -231,11 +231,13 @@ class API_folder_store:
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
hash TEXT PRIMARY KEY NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (hash) REFERENCES files(hash) ON DELETE CASCADE
|
||||
FOREIGN KEY (hash) REFERENCES files(hash) ON DELETE CASCADE,
|
||||
PRIMARY KEY (hash, name)
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -261,6 +263,11 @@ class API_folder_store:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_worker_type ON worker(worker_type)")
|
||||
|
||||
self._migrate_metadata_schema(cursor)
|
||||
self._migrate_notes_schema(cursor)
|
||||
|
||||
# Notes indices (after migration so columns exist)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_notes_hash ON notes(hash)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_notes_name ON notes(name)")
|
||||
self.connection.commit()
|
||||
logger.debug("Database tables created/verified")
|
||||
|
||||
@@ -448,6 +455,42 @@ class API_folder_store:
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
logger.debug(f"Note: Schema import/migration completed with status: {e}")
|
||||
|
||||
def _migrate_notes_schema(self, cursor) -> None:
|
||||
"""Migrate legacy notes schema (hash PRIMARY KEY, note) to named notes (hash,name PRIMARY KEY)."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(notes)")
|
||||
cols = [row[1] for row in cursor.fetchall()]
|
||||
if not cols:
|
||||
return
|
||||
if "name" in cols:
|
||||
return
|
||||
|
||||
logger.info("Migrating legacy notes table to named notes schema")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notes_new (
|
||||
hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (hash) REFERENCES files(hash) ON DELETE CASCADE,
|
||||
PRIMARY KEY (hash, name)
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy existing notes into the default key
|
||||
cursor.execute("""
|
||||
INSERT INTO notes_new (hash, name, note, created_at, updated_at)
|
||||
SELECT hash, 'default', note, created_at, updated_at
|
||||
FROM notes
|
||||
""")
|
||||
|
||||
cursor.execute("DROP TABLE notes")
|
||||
cursor.execute("ALTER TABLE notes_new RENAME TO notes")
|
||||
self.connection.commit()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Notes schema migration skipped/failed: {exc}")
|
||||
|
||||
def _update_metadata_modified_time(self, file_hash: str) -> None:
|
||||
"""Update the time_modified timestamp for a file's metadata."""
|
||||
@@ -1052,40 +1095,78 @@ class API_folder_store:
|
||||
return []
|
||||
|
||||
def get_note(self, file_hash: str) -> Optional[str]:
|
||||
"""Get note for a file by hash."""
|
||||
"""Get the default note for a file by hash."""
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT n.note FROM notes n
|
||||
WHERE n.hash = ?
|
||||
""", (file_hash,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
notes = self.get_notes(file_hash)
|
||||
if not notes:
|
||||
return None
|
||||
return notes.get("default")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting note for hash {file_hash}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_notes(self, file_hash: str) -> Dict[str, str]:
|
||||
"""Get all notes for a file by hash."""
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name, note FROM notes WHERE hash = ? ORDER BY name ASC",
|
||||
(file_hash,),
|
||||
)
|
||||
out: Dict[str, str] = {}
|
||||
for name, note in cursor.fetchall() or []:
|
||||
if not name:
|
||||
continue
|
||||
out[str(name)] = str(note or "")
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting notes for hash {file_hash}: {e}", exc_info=True)
|
||||
return {}
|
||||
|
||||
def save_note(self, file_path: Path, note: str) -> None:
|
||||
"""Save note for a file."""
|
||||
"""Save the default note for a file."""
|
||||
self.set_note(file_path, "default", note)
|
||||
|
||||
def set_note(self, file_path: Path, name: str, note: str) -> None:
|
||||
"""Set a named note for a file."""
|
||||
try:
|
||||
note_name = str(name or "").strip()
|
||||
if not note_name:
|
||||
raise ValueError("Note name is required")
|
||||
|
||||
file_hash = self.get_or_create_file_entry(file_path)
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO notes (hash, note)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO notes (hash, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(hash, name) DO UPDATE SET
|
||||
note = excluded.note,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (file_hash, note))
|
||||
|
||||
""",
|
||||
(file_hash, note_name, note),
|
||||
)
|
||||
self.connection.commit()
|
||||
logger.debug(f"Saved note for {file_path}")
|
||||
logger.debug(f"Saved note '{note_name}' for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving note for {file_path}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def delete_note(self, file_hash: str, name: str) -> None:
|
||||
"""Delete a named note for a file by hash."""
|
||||
try:
|
||||
note_name = str(name or "").strip()
|
||||
if not note_name:
|
||||
raise ValueError("Note name is required")
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM notes WHERE hash = ? AND name = ?",
|
||||
(file_hash, note_name),
|
||||
)
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting note '{name}' for hash {file_hash}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def search_by_tag(self, tag: str, limit: int = 100) -> List[tuple]:
|
||||
"""Search for files with a specific tag. Returns list of (hash, file_path) tuples."""
|
||||
@@ -2027,7 +2108,7 @@ def migrate_tags_to_db(library_root: Path, db: API_folder_store) -> int:
|
||||
try:
|
||||
for tags_file in library_root.rglob("*.tag"):
|
||||
try:
|
||||
base_path = tags_file.with_suffix("")
|
||||
base_path = tags_file.with_suffix("")
|
||||
tags_text = tags_file.read_text(encoding='utf-8')
|
||||
tags = [line.strip() for line in tags_text.splitlines() if line.strip()]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user