bearkit 1.6.0__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.
- bearkit/__init__.py +54 -0
- bearkit/actions.py +71 -0
- bearkit/bear.py +159 -0
- bearkit/db.py +343 -0
- bearkit/markdown.py +45 -0
- bearkit/ops.py +150 -0
- bearkit/py.typed +0 -0
- bearkit/search.py +121 -0
- bearkit/secrets.py +242 -0
- bearkit-1.6.0.dist-info/METADATA +39 -0
- bearkit-1.6.0.dist-info/RECORD +13 -0
- bearkit-1.6.0.dist-info/WHEEL +4 -0
- bearkit-1.6.0.dist-info/licenses/LICENSE +21 -0
bearkit/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""bearkit: the fundamentals for interacting with the Bear notes app.
|
|
2
|
+
|
|
3
|
+
- `Bear`: the facade and recommended entry point - read notes, write them
|
|
4
|
+
through the Bear app (verified), all from one object.
|
|
5
|
+
- `db` / `BearDB`: the raw read-only database layer (strictly read-only).
|
|
6
|
+
- `actions`: Bear's x-callback-url write API (fire-and-forget URLs).
|
|
7
|
+
- `ops`: verified write operations - fire an action, confirm it via the
|
|
8
|
+
database, return the fresh note, or raise `BearWriteError`.
|
|
9
|
+
- `search`: naive and fuzzy search over notes.
|
|
10
|
+
- `secrets`: offline secret detection and redaction for note text.
|
|
11
|
+
- `markdown`: Bear markdown conventions (attachment links, tag markers).
|
|
12
|
+
|
|
13
|
+
Nothing in this package depends on a UI; bearcli's CLI and TUI are built
|
|
14
|
+
on top of it. macOS only (Bear is a macOS/iOS app).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from bearkit import actions, db, markdown, ops, search, secrets
|
|
18
|
+
from bearkit.bear import Bear
|
|
19
|
+
from bearkit.db import (
|
|
20
|
+
DEFAULT_DB_PATH,
|
|
21
|
+
AmbiguousNoteId,
|
|
22
|
+
Attachment,
|
|
23
|
+
BearDB,
|
|
24
|
+
Note,
|
|
25
|
+
NoteFilter,
|
|
26
|
+
)
|
|
27
|
+
from bearkit.ops import BearWriteError, TagMarkerNotFound, TextMode
|
|
28
|
+
from bearkit.search import SearchResult, naive_search, search_notes
|
|
29
|
+
from bearkit.secrets import ScanReport, SecretFinding, scan_notes
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_DB_PATH",
|
|
33
|
+
"AmbiguousNoteId",
|
|
34
|
+
"Attachment",
|
|
35
|
+
"Bear",
|
|
36
|
+
"BearDB",
|
|
37
|
+
"BearWriteError",
|
|
38
|
+
"Note",
|
|
39
|
+
"NoteFilter",
|
|
40
|
+
"ScanReport",
|
|
41
|
+
"SearchResult",
|
|
42
|
+
"SecretFinding",
|
|
43
|
+
"TagMarkerNotFound",
|
|
44
|
+
"TextMode",
|
|
45
|
+
"actions",
|
|
46
|
+
"db",
|
|
47
|
+
"markdown",
|
|
48
|
+
"naive_search",
|
|
49
|
+
"ops",
|
|
50
|
+
"scan_notes",
|
|
51
|
+
"search",
|
|
52
|
+
"search_notes",
|
|
53
|
+
"secrets",
|
|
54
|
+
]
|
bearkit/actions.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Write actions via Bear's x-callback-url scheme.
|
|
2
|
+
|
|
3
|
+
The database stays strictly read-only; all mutations go through Bear's own
|
|
4
|
+
URL API (which launches the app if needed). Calls are fire-and-forget — Bear
|
|
5
|
+
gives no result back — so callers verify outcomes by re-reading the database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from urllib.parse import quote, urlencode
|
|
14
|
+
|
|
15
|
+
BASE_URL = "bear://x-callback-url/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def call_bear(action: str, foreground: bool = False, **params: str | None) -> None:
|
|
19
|
+
query = urlencode({k: v for k, v in params.items() if v is not None}, quote_via=quote)
|
|
20
|
+
# -g keeps Bear in the background instead of stealing focus.
|
|
21
|
+
cmd = ["open", f"{BASE_URL}{action}?{query}"] if foreground else ["open", "-g", f"{BASE_URL}{action}?{query}"]
|
|
22
|
+
subprocess.run(cmd, check=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_note(title: str, text: str | None = None, tags: list[str] | None = None) -> None:
|
|
26
|
+
call_bear(
|
|
27
|
+
"create",
|
|
28
|
+
title=title,
|
|
29
|
+
text=text,
|
|
30
|
+
tags=",".join(tags) if tags else None,
|
|
31
|
+
open_note="no",
|
|
32
|
+
show_window="no",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_text(note_id: str, text: str, mode: str = "append") -> None:
|
|
37
|
+
call_bear("add-text", id=note_id, text=text, mode=mode, open_note="no", show_window="no")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_file(note_id: str, filename: str, file_b64: str) -> None:
|
|
41
|
+
call_bear("add-file", id=note_id, filename=filename, file=file_b64, mode="append", open_note="no", show_window="no")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rename_tag(name: str, new_name: str) -> None:
|
|
45
|
+
call_bear("rename-tag", name=name, new_name=new_name, show_window="no")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def delete_tag(name: str) -> None:
|
|
49
|
+
call_bear("delete-tag", name=name, show_window="no")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def open_note(note_id: str, new_window: bool = False) -> None:
|
|
53
|
+
call_bear("open-note", foreground=True, id=note_id, new_window="yes" if new_window else "no")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def trash_note(note_id: str) -> None:
|
|
57
|
+
call_bear("trash", id=note_id, show_window="no")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def archive_note(note_id: str) -> None:
|
|
61
|
+
call_bear("archive", id=note_id, show_window="no")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wait_for(predicate: Callable[[], bool], timeout: float = 6.0, interval: float = 0.3) -> bool:
|
|
65
|
+
"""Poll until predicate() is true; Bear applies URL actions asynchronously."""
|
|
66
|
+
deadline = time.monotonic() + timeout
|
|
67
|
+
while time.monotonic() < deadline:
|
|
68
|
+
if predicate():
|
|
69
|
+
return True
|
|
70
|
+
time.sleep(interval)
|
|
71
|
+
return predicate()
|
bearkit/bear.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The Bear facade: one object for reading and (verified) writing.
|
|
2
|
+
|
|
3
|
+
The recommended entry point for library users. Reads go straight to the
|
|
4
|
+
read-only database; writes go through Bear's x-callback API and are
|
|
5
|
+
verified against the database before returning (raising `BearWriteError`
|
|
6
|
+
if Bear never applies the change). The raw layers remain available as
|
|
7
|
+
`bear.db`, `bearkit.ops`, and `bearkit.actions`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
|
|
17
|
+
from bearkit import actions, ops
|
|
18
|
+
from bearkit.db import DEFAULT_DB_PATH, BearDB, Note, NoteFilter
|
|
19
|
+
from bearkit.ops import TextMode
|
|
20
|
+
from bearkit.search import SearchResult, naive_search, search_notes
|
|
21
|
+
from bearkit.secrets import ScanReport, scan_notes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Bear:
|
|
25
|
+
"""A Bear library: read notes, and write them through the Bear app."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: Path = DEFAULT_DB_PATH):
|
|
28
|
+
self.db = BearDB(path)
|
|
29
|
+
|
|
30
|
+
def close(self) -> None:
|
|
31
|
+
self.db.close()
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> Bear:
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def __exit__(
|
|
37
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
38
|
+
) -> None:
|
|
39
|
+
self.close()
|
|
40
|
+
|
|
41
|
+
# ── reading ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def list_notes(
|
|
44
|
+
self,
|
|
45
|
+
limit: int | None = None,
|
|
46
|
+
tag: str | None = None,
|
|
47
|
+
created_after: datetime | None = None,
|
|
48
|
+
created_before: datetime | None = None,
|
|
49
|
+
modified_after: datetime | None = None,
|
|
50
|
+
modified_before: datetime | None = None,
|
|
51
|
+
only: NoteFilter | str | None = None,
|
|
52
|
+
include_trashed: bool = False,
|
|
53
|
+
include_archived: bool = False,
|
|
54
|
+
) -> list[Note]:
|
|
55
|
+
"""Notes, most recently modified first (see BearDB.list_notes)."""
|
|
56
|
+
return self.db.list_notes(
|
|
57
|
+
limit=limit,
|
|
58
|
+
tag=tag,
|
|
59
|
+
created_after=created_after,
|
|
60
|
+
created_before=created_before,
|
|
61
|
+
modified_after=modified_after,
|
|
62
|
+
modified_before=modified_before,
|
|
63
|
+
only=only,
|
|
64
|
+
include_trashed=include_trashed,
|
|
65
|
+
include_archived=include_archived,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def get_note(self, note_id: str) -> Note | None:
|
|
69
|
+
"""Fetch a note by id or unique 4+ char prefix; None when absent."""
|
|
70
|
+
return self.db.get_note(note_id)
|
|
71
|
+
|
|
72
|
+
def list_tags(self, include_empty: bool = False) -> list[tuple[str, int]]:
|
|
73
|
+
"""All tags with their note counts."""
|
|
74
|
+
return self.db.list_tags(include_empty=include_empty)
|
|
75
|
+
|
|
76
|
+
def attachment_stats(self) -> tuple[int, int]:
|
|
77
|
+
"""(count, total bytes) of attachments on non-trashed notes."""
|
|
78
|
+
return self.db.attachment_stats()
|
|
79
|
+
|
|
80
|
+
def search(
|
|
81
|
+
self,
|
|
82
|
+
query: str,
|
|
83
|
+
fuzzy: bool = False,
|
|
84
|
+
min_score: float = 60.0,
|
|
85
|
+
tag: str | Sequence[str] | None = None,
|
|
86
|
+
include_trashed: bool = False,
|
|
87
|
+
include_archived: bool = False,
|
|
88
|
+
) -> list[SearchResult]:
|
|
89
|
+
"""Search titles, tags, and content; fuzzy is typo-tolerant and ranked.
|
|
90
|
+
|
|
91
|
+
`tag` scopes the search to one tag or any of several (each including
|
|
92
|
+
its nested sub-tags, e.g. "work" also covers "work/ideas").
|
|
93
|
+
"""
|
|
94
|
+
single = tag if isinstance(tag, str) else None
|
|
95
|
+
notes = self.list_notes(
|
|
96
|
+
limit=None, tag=single, include_trashed=include_trashed, include_archived=include_archived
|
|
97
|
+
)
|
|
98
|
+
if tag and not isinstance(tag, str):
|
|
99
|
+
wanted = [t.lower() for t in tag]
|
|
100
|
+
notes = [
|
|
101
|
+
n
|
|
102
|
+
for n in notes
|
|
103
|
+
if any(nt == w or nt.startswith(w + "/") for nt in (t.lower() for t in n.tags) for w in wanted)
|
|
104
|
+
]
|
|
105
|
+
if fuzzy:
|
|
106
|
+
return search_notes(notes, query, min_score=min_score)
|
|
107
|
+
return naive_search(notes, query)
|
|
108
|
+
|
|
109
|
+
def scan_secrets(self, notes: list[Note] | None = None) -> ScanReport:
|
|
110
|
+
"""Scan for secrets; defaults to every note, archived included."""
|
|
111
|
+
return scan_notes(notes if notes is not None else self.list_notes(limit=None, include_archived=True))
|
|
112
|
+
|
|
113
|
+
# ── verified writing ─────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def create_note(self, title: str, text: str | None = None, tags: list[str] | None = None) -> Note:
|
|
116
|
+
"""Create a note and return it once it appears in the database."""
|
|
117
|
+
return ops.create_note(self.db, title, text, tags)
|
|
118
|
+
|
|
119
|
+
def add_text(self, note: Note, text: str, mode: TextMode | str = TextMode.APPEND) -> Note:
|
|
120
|
+
"""Append/prepend/replace note text."""
|
|
121
|
+
return ops.add_text(self.db, note, text, mode)
|
|
122
|
+
|
|
123
|
+
def rename(self, note: Note, new_title: str) -> Note:
|
|
124
|
+
"""Replace the heading line, keeping the body."""
|
|
125
|
+
return ops.rename(self.db, note, new_title)
|
|
126
|
+
|
|
127
|
+
def add_tag(self, note: Note, name: str) -> Note:
|
|
128
|
+
"""Add an inline tag marker to the note."""
|
|
129
|
+
return ops.add_tag(self.db, note, name)
|
|
130
|
+
|
|
131
|
+
def remove_tag(self, note: Note, name: str) -> Note:
|
|
132
|
+
"""Remove the tag's inline marker; raises TagMarkerNotFound if absent."""
|
|
133
|
+
return ops.remove_tag(self.db, note, name)
|
|
134
|
+
|
|
135
|
+
def attach_file(self, note: Note, filename: str, file_b64: str) -> Note:
|
|
136
|
+
"""Attach a base64-encoded file (keep it under ~500 KB)."""
|
|
137
|
+
return ops.attach_file(self.db, note, filename, file_b64)
|
|
138
|
+
|
|
139
|
+
def trash(self, note: Note) -> Note:
|
|
140
|
+
"""Move the note to Bear's trash (one-way: no untrash API)."""
|
|
141
|
+
return ops.trash(self.db, note)
|
|
142
|
+
|
|
143
|
+
def archive(self, note: Note) -> Note:
|
|
144
|
+
"""Archive the note (one-way: no unarchive API)."""
|
|
145
|
+
return ops.archive(self.db, note)
|
|
146
|
+
|
|
147
|
+
def rename_tag(self, name: str, new_name: str) -> None:
|
|
148
|
+
"""Rename a tag across all notes."""
|
|
149
|
+
ops.rename_tag(self.db, name, new_name)
|
|
150
|
+
|
|
151
|
+
def delete_tag(self, name: str) -> None:
|
|
152
|
+
"""Delete a tag from every note that has it."""
|
|
153
|
+
ops.delete_tag(self.db, name)
|
|
154
|
+
|
|
155
|
+
# ── the Bear app ─────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def open(self, note: Note, new_window: bool = False) -> None:
|
|
158
|
+
"""Bring the note up in the Bear app."""
|
|
159
|
+
actions.open_note(note.id, new_window=new_window)
|
bearkit/db.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Read-only access to Bear's SQLite database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
DEFAULT_DB_PATH = (
|
|
14
|
+
Path.home() / "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear" / "Application Data/database.sqlite"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Core Data stores timestamps as seconds since 2001-01-01 UTC.
|
|
18
|
+
CORE_DATA_EPOCH = datetime(2001, 1, 1, tzinfo=UTC)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def core_data_to_datetime(value: float | None) -> datetime | None:
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
return (CORE_DATA_EPOCH + timedelta(seconds=value)).astimezone()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def datetime_to_core_data(value: datetime) -> float:
|
|
28
|
+
if value.tzinfo is None:
|
|
29
|
+
value = value.astimezone()
|
|
30
|
+
return (value - CORE_DATA_EPOCH).total_seconds()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NoteFilter(StrEnum):
|
|
34
|
+
"""Restrict a listing to notes carrying exactly this status."""
|
|
35
|
+
|
|
36
|
+
PINNED = "pinned"
|
|
37
|
+
ENCRYPTED = "encrypted"
|
|
38
|
+
TRASHED = "trashed"
|
|
39
|
+
ARCHIVED = "archived"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Attachment:
|
|
44
|
+
id: str
|
|
45
|
+
filename: str
|
|
46
|
+
path: Path
|
|
47
|
+
size: int | None
|
|
48
|
+
exists: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Note:
|
|
53
|
+
id: str
|
|
54
|
+
title: str
|
|
55
|
+
created: datetime | None
|
|
56
|
+
modified: datetime | None
|
|
57
|
+
pinned: bool
|
|
58
|
+
encrypted: bool
|
|
59
|
+
archived: bool
|
|
60
|
+
trashed: bool
|
|
61
|
+
tags: list[str] = field(default_factory=list)
|
|
62
|
+
text: str | None = None
|
|
63
|
+
"""The full markdown content; None if and only if the note is encrypted."""
|
|
64
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
def has_tag(self, name: str) -> bool:
|
|
67
|
+
"""Whether the note carries the tag (case-insensitive, exact name)."""
|
|
68
|
+
return name.lower() in (t.lower() for t in self.tags)
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict:
|
|
71
|
+
"""Serializable metadata: id, title, tags, ISO dates, status flags."""
|
|
72
|
+
return {
|
|
73
|
+
"id": self.id,
|
|
74
|
+
"title": self.title,
|
|
75
|
+
"tags": self.tags,
|
|
76
|
+
"created": self.created.isoformat() if self.created else None,
|
|
77
|
+
"modified": self.modified.isoformat() if self.modified else None,
|
|
78
|
+
"pinned": self.pinned,
|
|
79
|
+
"encrypted": self.encrypted,
|
|
80
|
+
"archived": self.archived,
|
|
81
|
+
"trashed": self.trashed,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def status_line(self) -> str:
|
|
86
|
+
"""Comma-joined status flags for display, e.g. "pinned,archived"."""
|
|
87
|
+
flags = (
|
|
88
|
+
("pinned", self.pinned),
|
|
89
|
+
("encrypted", self.encrypted),
|
|
90
|
+
("trashed", self.trashed),
|
|
91
|
+
("archived", self.archived),
|
|
92
|
+
)
|
|
93
|
+
return ",".join(name for name, on in flags if on)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AmbiguousNoteId(Exception):
|
|
97
|
+
"""A note id prefix matched more than one note."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, prefix: str, matches: list[tuple[str, str]]):
|
|
100
|
+
super().__init__(f"note id prefix {prefix!r} is ambiguous")
|
|
101
|
+
self.prefix = prefix
|
|
102
|
+
self.matches = matches # (id, title) pairs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
MIN_ID_PREFIX = 4
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BearDB:
|
|
109
|
+
def __init__(self, path: Path = DEFAULT_DB_PATH):
|
|
110
|
+
if not path.exists():
|
|
111
|
+
raise FileNotFoundError(f"Bear database not found at {path}")
|
|
112
|
+
self.conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
113
|
+
self.conn.row_factory = sqlite3.Row
|
|
114
|
+
self.files_dir = path.parent / "Local Files"
|
|
115
|
+
self._tags_join = self._detect_tags_join()
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self.conn.close()
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> BearDB:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(
|
|
124
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
125
|
+
) -> None:
|
|
126
|
+
self.close()
|
|
127
|
+
|
|
128
|
+
def _detect_tags_join(self) -> tuple[str, str, str]:
|
|
129
|
+
"""Find the note<->tag join table; its numeric prefixes vary by Bear version.
|
|
130
|
+
|
|
131
|
+
Returns (table, note_column, tag_column).
|
|
132
|
+
"""
|
|
133
|
+
rows = self.conn.execute(
|
|
134
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'Z\\_%TAGS' ESCAPE '\\'"
|
|
135
|
+
).fetchall()
|
|
136
|
+
for (table,) in rows:
|
|
137
|
+
if not re.fullmatch(r"Z_\d+TAGS", table):
|
|
138
|
+
continue
|
|
139
|
+
columns = [r["name"] for r in self.conn.execute(f"PRAGMA table_info({table})")]
|
|
140
|
+
note_col = next((c for c in columns if re.fullmatch(r"Z_\d+NOTES", c)), None)
|
|
141
|
+
tag_col = next((c for c in columns if re.fullmatch(r"Z_\d+TAGS", c)), None)
|
|
142
|
+
if note_col and tag_col:
|
|
143
|
+
return table, note_col, tag_col
|
|
144
|
+
raise RuntimeError("Could not locate the note/tag join table in the Bear database")
|
|
145
|
+
|
|
146
|
+
def _tags_for_note(self, note_pk: int) -> list[str]:
|
|
147
|
+
table, note_col, tag_col = self._tags_join
|
|
148
|
+
rows = self.conn.execute(
|
|
149
|
+
f"""
|
|
150
|
+
SELECT t.ZTITLE FROM ZSFNOTETAG t
|
|
151
|
+
JOIN {table} j ON j.{tag_col} = t.Z_PK
|
|
152
|
+
WHERE j.{note_col} = ?
|
|
153
|
+
ORDER BY t.ZTITLE
|
|
154
|
+
""",
|
|
155
|
+
(note_pk,),
|
|
156
|
+
).fetchall()
|
|
157
|
+
return [r["ZTITLE"] for r in rows]
|
|
158
|
+
|
|
159
|
+
def list_tags(self, include_empty: bool = False) -> list[tuple[str, int]]:
|
|
160
|
+
"""All tags with their note counts (excluding trashed/deleted notes).
|
|
161
|
+
|
|
162
|
+
Bear keeps tag rows around after their last note is untagged (the app
|
|
163
|
+
hides them), so empty tags are excluded unless include_empty is set.
|
|
164
|
+
"""
|
|
165
|
+
table, note_col, tag_col = self._tags_join
|
|
166
|
+
having = "" if include_empty else "HAVING COUNT(n.Z_PK) > 0"
|
|
167
|
+
rows = self.conn.execute(
|
|
168
|
+
f"""
|
|
169
|
+
SELECT t.ZTITLE, COUNT(n.Z_PK)
|
|
170
|
+
FROM ZSFNOTETAG t
|
|
171
|
+
LEFT JOIN {table} j ON j.{tag_col} = t.Z_PK
|
|
172
|
+
LEFT JOIN ZSFNOTE n
|
|
173
|
+
ON n.Z_PK = j.{note_col} AND n.ZTRASHED = 0 AND n.ZPERMANENTLYDELETED = 0
|
|
174
|
+
GROUP BY t.Z_PK
|
|
175
|
+
{having}
|
|
176
|
+
ORDER BY t.ZTITLE
|
|
177
|
+
"""
|
|
178
|
+
).fetchall()
|
|
179
|
+
return [(row[0], row[1]) for row in rows]
|
|
180
|
+
|
|
181
|
+
def attachment_stats(self) -> tuple[int, int]:
|
|
182
|
+
"""(count, total bytes) of attachments on non-deleted notes."""
|
|
183
|
+
row = self.conn.execute(
|
|
184
|
+
"""
|
|
185
|
+
SELECT COUNT(*), COALESCE(SUM(f.ZFILESIZE), 0)
|
|
186
|
+
FROM ZSFNOTEFILE f
|
|
187
|
+
JOIN ZSFNOTE n ON n.Z_PK = f.ZNOTE
|
|
188
|
+
WHERE f.ZPERMANENTLYDELETED = 0 AND n.ZPERMANENTLYDELETED = 0 AND n.ZTRASHED = 0
|
|
189
|
+
"""
|
|
190
|
+
).fetchone()
|
|
191
|
+
return row[0], row[1]
|
|
192
|
+
|
|
193
|
+
def _attachments_for_note(self, note_pk: int) -> list[Attachment]:
|
|
194
|
+
rows = self.conn.execute(
|
|
195
|
+
"""
|
|
196
|
+
SELECT ZUNIQUEIDENTIFIER, ZFILENAME, ZFILESIZE FROM ZSFNOTEFILE
|
|
197
|
+
WHERE ZNOTE = ? AND ZPERMANENTLYDELETED = 0 ORDER BY ZFILENAME
|
|
198
|
+
""",
|
|
199
|
+
(note_pk,),
|
|
200
|
+
).fetchall()
|
|
201
|
+
attachments = []
|
|
202
|
+
for row in rows:
|
|
203
|
+
# Images live under "Note Images", everything else under "Note Files";
|
|
204
|
+
# the database doesn't record which, so probe both.
|
|
205
|
+
candidates = [
|
|
206
|
+
self.files_dir / subdir / row["ZUNIQUEIDENTIFIER"] / row["ZFILENAME"]
|
|
207
|
+
for subdir in ("Note Images", "Note Files")
|
|
208
|
+
]
|
|
209
|
+
path = next((c for c in candidates if c.exists()), candidates[0])
|
|
210
|
+
attachments.append(
|
|
211
|
+
Attachment(
|
|
212
|
+
id=row["ZUNIQUEIDENTIFIER"],
|
|
213
|
+
filename=row["ZFILENAME"],
|
|
214
|
+
path=path,
|
|
215
|
+
size=row["ZFILESIZE"],
|
|
216
|
+
exists=path.exists(),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return attachments
|
|
220
|
+
|
|
221
|
+
def list_notes(
|
|
222
|
+
self,
|
|
223
|
+
limit: int | None = None,
|
|
224
|
+
tag: str | None = None,
|
|
225
|
+
created_after: datetime | None = None,
|
|
226
|
+
created_before: datetime | None = None,
|
|
227
|
+
modified_after: datetime | None = None,
|
|
228
|
+
modified_before: datetime | None = None,
|
|
229
|
+
only: NoteFilter | str | None = None,
|
|
230
|
+
include_trashed: bool = False,
|
|
231
|
+
include_archived: bool = False,
|
|
232
|
+
) -> list[Note]:
|
|
233
|
+
"""Notes, most recently modified first.
|
|
234
|
+
|
|
235
|
+
Trashed and archived notes are excluded unless included explicitly or
|
|
236
|
+
selected via `only`; permanently-deleted rows never appear.
|
|
237
|
+
"""
|
|
238
|
+
only = NoteFilter(only) if only is not None else None
|
|
239
|
+
table, note_col, tag_col = self._tags_join
|
|
240
|
+
# Deleted-pending-sync rows linger in the table; never show them.
|
|
241
|
+
where = ["n.ZPERMANENTLYDELETED = 0"]
|
|
242
|
+
params: list[object] = []
|
|
243
|
+
|
|
244
|
+
if only is NoteFilter.PINNED:
|
|
245
|
+
where.append("n.ZPINNED = 1")
|
|
246
|
+
elif only is NoteFilter.ENCRYPTED:
|
|
247
|
+
where.append("n.ZENCRYPTED = 1")
|
|
248
|
+
|
|
249
|
+
if only is NoteFilter.TRASHED:
|
|
250
|
+
where.append("n.ZTRASHED = 1")
|
|
251
|
+
elif not include_trashed:
|
|
252
|
+
where.append("n.ZTRASHED = 0")
|
|
253
|
+
if only is NoteFilter.ARCHIVED:
|
|
254
|
+
where.append("n.ZARCHIVED = 1")
|
|
255
|
+
elif not include_archived and only is not NoteFilter.TRASHED:
|
|
256
|
+
# A note trashed from the archive keeps both flags; Bear shows it in
|
|
257
|
+
# the trash, so --only trashed must not exclude archived notes.
|
|
258
|
+
where.append("n.ZARCHIVED = 0")
|
|
259
|
+
if tag:
|
|
260
|
+
# Match the tag itself and its nested sub-tags (e.g. "work" matches "work/ideas").
|
|
261
|
+
where.append(
|
|
262
|
+
f"""n.Z_PK IN (
|
|
263
|
+
SELECT j.{note_col} FROM {table} j
|
|
264
|
+
JOIN ZSFNOTETAG t ON t.Z_PK = j.{tag_col}
|
|
265
|
+
WHERE t.ZTITLE = ? OR t.ZTITLE LIKE ? ESCAPE '\\'
|
|
266
|
+
)"""
|
|
267
|
+
)
|
|
268
|
+
escaped = tag.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
269
|
+
params += [tag, f"{escaped}/%"]
|
|
270
|
+
if created_after:
|
|
271
|
+
where.append("n.ZCREATIONDATE >= ?")
|
|
272
|
+
params.append(datetime_to_core_data(created_after))
|
|
273
|
+
if created_before:
|
|
274
|
+
where.append("n.ZCREATIONDATE < ?")
|
|
275
|
+
params.append(datetime_to_core_data(created_before))
|
|
276
|
+
if modified_after:
|
|
277
|
+
where.append("n.ZMODIFICATIONDATE >= ?")
|
|
278
|
+
params.append(datetime_to_core_data(modified_after))
|
|
279
|
+
if modified_before:
|
|
280
|
+
where.append("n.ZMODIFICATIONDATE < ?")
|
|
281
|
+
params.append(datetime_to_core_data(modified_before))
|
|
282
|
+
|
|
283
|
+
query = """
|
|
284
|
+
SELECT n.Z_PK, n.ZUNIQUEIDENTIFIER, n.ZTITLE, n.ZCREATIONDATE,
|
|
285
|
+
n.ZMODIFICATIONDATE, n.ZPINNED, n.ZENCRYPTED, n.ZARCHIVED, n.ZTRASHED, n.ZTEXT
|
|
286
|
+
FROM ZSFNOTE n
|
|
287
|
+
"""
|
|
288
|
+
if where:
|
|
289
|
+
query += " WHERE " + " AND ".join(where)
|
|
290
|
+
query += " ORDER BY n.ZMODIFICATIONDATE DESC"
|
|
291
|
+
if limit is not None:
|
|
292
|
+
query += " LIMIT ?"
|
|
293
|
+
params.append(limit)
|
|
294
|
+
|
|
295
|
+
return [self._note_from_row(row, text=row["ZTEXT"]) for row in self.conn.execute(query, params)]
|
|
296
|
+
|
|
297
|
+
def get_note(self, note_id: str) -> Note | None:
|
|
298
|
+
"""Fetch a note by id; a unique prefix (>= 4 chars) works like a full id."""
|
|
299
|
+
row = self.conn.execute(
|
|
300
|
+
"""
|
|
301
|
+
SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
|
|
302
|
+
ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
|
|
303
|
+
FROM ZSFNOTE
|
|
304
|
+
WHERE ZUNIQUEIDENTIFIER = ? COLLATE NOCASE
|
|
305
|
+
""",
|
|
306
|
+
(note_id,),
|
|
307
|
+
).fetchone()
|
|
308
|
+
if row is None and len(note_id) >= MIN_ID_PREFIX:
|
|
309
|
+
escaped = note_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
310
|
+
matches = self.conn.execute(
|
|
311
|
+
"""
|
|
312
|
+
SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
|
|
313
|
+
ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
|
|
314
|
+
FROM ZSFNOTE
|
|
315
|
+
WHERE ZUNIQUEIDENTIFIER LIKE ? ESCAPE '\\' COLLATE NOCASE AND ZPERMANENTLYDELETED = 0
|
|
316
|
+
LIMIT 6
|
|
317
|
+
""",
|
|
318
|
+
(f"{escaped}%",),
|
|
319
|
+
).fetchall()
|
|
320
|
+
if len(matches) > 1:
|
|
321
|
+
raise AmbiguousNoteId(note_id, [(r["ZUNIQUEIDENTIFIER"], r["ZTITLE"] or "(untitled)") for r in matches])
|
|
322
|
+
if matches:
|
|
323
|
+
row = matches[0]
|
|
324
|
+
if row is None:
|
|
325
|
+
return None
|
|
326
|
+
return self._note_from_row(row, text=row["ZTEXT"], attachments=self._attachments_for_note(row["Z_PK"]))
|
|
327
|
+
|
|
328
|
+
def _note_from_row(
|
|
329
|
+
self, row: sqlite3.Row, text: str | None = None, attachments: list[Attachment] | None = None
|
|
330
|
+
) -> Note:
|
|
331
|
+
return Note(
|
|
332
|
+
id=row["ZUNIQUEIDENTIFIER"],
|
|
333
|
+
title=row["ZTITLE"] or "(untitled)",
|
|
334
|
+
created=core_data_to_datetime(row["ZCREATIONDATE"]),
|
|
335
|
+
modified=core_data_to_datetime(row["ZMODIFICATIONDATE"]),
|
|
336
|
+
pinned=bool(row["ZPINNED"]),
|
|
337
|
+
encrypted=bool(row["ZENCRYPTED"]),
|
|
338
|
+
archived=bool(row["ZARCHIVED"]),
|
|
339
|
+
trashed=bool(row["ZTRASHED"]),
|
|
340
|
+
tags=self._tags_for_note(row["Z_PK"]),
|
|
341
|
+
text=text,
|
|
342
|
+
attachments=attachments or [],
|
|
343
|
+
)
|
bearkit/markdown.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Helpers for Bear's markdown conventions, shared by the CLI, TUI, and export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from bearkit.db import Attachment, Note
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def rewrite_attachment_refs(note: Note, target_for: Callable[[Attachment], str]) -> str:
|
|
13
|
+
"""Rewrite bare attachment filename links to per-attachment targets.
|
|
14
|
+
|
|
15
|
+
Bear references attachments by bare filename and percent-encodes it in the
|
|
16
|
+
note text (spaces as %20), so both the raw and encoded forms must be
|
|
17
|
+
matched; targets are emitted percent-encoded to keep the links valid.
|
|
18
|
+
Only filenames known from the attachment records are rewritten — regular
|
|
19
|
+
URLs in the text are never touched.
|
|
20
|
+
"""
|
|
21
|
+
text = note.text or ""
|
|
22
|
+
for att in note.attachments:
|
|
23
|
+
target = quote(target_for(att))
|
|
24
|
+
for ref in {att.filename, quote(att.filename)}:
|
|
25
|
+
text = text.replace(f"]({ref})", f"]({target})")
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def tag_marker(name: str) -> str:
|
|
30
|
+
"""The inline marker Bear uses for a tag: #name, or #name# when it needs delimiting."""
|
|
31
|
+
return f"#{name}#" if re.search(r"[^\w/-]", name) else f"#{name}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def remove_tag_marker(text: str, name: str) -> str | None:
|
|
35
|
+
"""The text with the tag's inline markers stripped, or None if none matched.
|
|
36
|
+
|
|
37
|
+
Longer tags sharing the prefix are left alone: removing "work" must not
|
|
38
|
+
touch "#work/ideas" or "#workout".
|
|
39
|
+
"""
|
|
40
|
+
escaped = re.escape(name)
|
|
41
|
+
stripped = re.sub(rf"[ \t]?#{escaped}#", "", text, flags=re.IGNORECASE)
|
|
42
|
+
stripped = re.sub(rf"[ \t]?#{escaped}(?![\w/-])", "", stripped, flags=re.IGNORECASE)
|
|
43
|
+
if stripped == text:
|
|
44
|
+
return None
|
|
45
|
+
return stripped.rstrip("\n") + "\n"
|
bearkit/ops.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Verified write operations.
|
|
2
|
+
|
|
3
|
+
Each function fires a Bear x-callback action, confirms the outcome by
|
|
4
|
+
re-reading the database, and returns the fresh note. If Bear never
|
|
5
|
+
observably applies the change within `VERIFY_TIMEOUT` seconds (typically:
|
|
6
|
+
Bear cannot run), `BearWriteError` is raised.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from bearkit import actions
|
|
16
|
+
from bearkit.db import BearDB, Note
|
|
17
|
+
from bearkit.markdown import remove_tag_marker, tag_marker
|
|
18
|
+
|
|
19
|
+
VERIFY_TIMEOUT = 6.0
|
|
20
|
+
"""Seconds to wait for Bear to apply a write before raising BearWriteError."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TextMode(StrEnum):
|
|
24
|
+
"""Where `add_text` puts the text (mirrors Bear's add-text modes)."""
|
|
25
|
+
|
|
26
|
+
APPEND = "append"
|
|
27
|
+
PREPEND = "prepend"
|
|
28
|
+
REPLACE = "replace" # replaces the body, keeps the title
|
|
29
|
+
REPLACE_ALL = "replace_all" # replaces everything including the title
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BearWriteError(RuntimeError):
|
|
33
|
+
"""Bear did not observably apply the write (is Bear able to run?)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TagMarkerNotFound(LookupError):
|
|
37
|
+
"""The note text contains no inline marker for the tag being removed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _confirmed(db: BearDB, note_id: str, operation: str, changed: Callable[[Note], bool]) -> Note:
|
|
41
|
+
def check() -> bool:
|
|
42
|
+
fresh = db.get_note(note_id)
|
|
43
|
+
return fresh is not None and changed(fresh)
|
|
44
|
+
|
|
45
|
+
if actions.wait_for(check, timeout=VERIFY_TIMEOUT):
|
|
46
|
+
fresh = db.get_note(note_id)
|
|
47
|
+
if fresh is not None:
|
|
48
|
+
return fresh
|
|
49
|
+
raise BearWriteError(f"{operation} was not applied to note {note_id}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _modified_after(before: Note) -> Callable[[Note], bool]:
|
|
53
|
+
return lambda n: n.modified is not None and (before.modified is None or n.modified > before.modified)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_note(db: BearDB, title: str, text: str | None, tags: list[str] | None = None) -> Note:
|
|
57
|
+
"""Create a note through Bear and return it once it appears in the database."""
|
|
58
|
+
started = datetime.now(UTC)
|
|
59
|
+
actions.create_note(title, text=text, tags=tags)
|
|
60
|
+
|
|
61
|
+
def find() -> Note | None:
|
|
62
|
+
return next(
|
|
63
|
+
(
|
|
64
|
+
n
|
|
65
|
+
for n in db.list_notes(limit=10)
|
|
66
|
+
if n.title == title and n.created and n.created >= started - timedelta(seconds=5)
|
|
67
|
+
),
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if actions.wait_for(lambda: find() is not None, timeout=VERIFY_TIMEOUT):
|
|
72
|
+
created = find()
|
|
73
|
+
if created is not None:
|
|
74
|
+
return created
|
|
75
|
+
raise BearWriteError(f"created note {title!r} did not appear in the database")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def add_text(db: BearDB, note: Note, text: str, mode: TextMode | str = TextMode.APPEND) -> Note:
|
|
79
|
+
"""Append/prepend/replace note text; verified by the modification date bump."""
|
|
80
|
+
actions.add_text(note.id, text, mode=TextMode(mode).value)
|
|
81
|
+
return _confirmed(db, note.id, "text change", _modified_after(note))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rename(db: BearDB, note: Note, new_title: str) -> Note:
|
|
85
|
+
"""Replace the heading line, keeping the body."""
|
|
86
|
+
head, sep, body = (note.text or "").partition("\n")
|
|
87
|
+
if head.startswith("# "):
|
|
88
|
+
new_text = f"# {new_title}{sep}{body}"
|
|
89
|
+
else:
|
|
90
|
+
new_text = f"# {new_title}\n{note.text or ''}"
|
|
91
|
+
actions.add_text(note.id, new_text, mode=TextMode.REPLACE_ALL.value)
|
|
92
|
+
return _confirmed(db, note.id, "rename", lambda n: n.title == new_title)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_tag(db: BearDB, note: Note, name: str) -> Note:
|
|
96
|
+
actions.add_text(note.id, tag_marker(name), mode=TextMode.APPEND.value)
|
|
97
|
+
return _confirmed(db, note.id, "tag", lambda n: n.has_tag(name))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remove_tag(db: BearDB, note: Note, name: str) -> Note:
|
|
101
|
+
"""Rewrite the note without the tag marker.
|
|
102
|
+
|
|
103
|
+
Raises TagMarkerNotFound when the text has no marker for the tag.
|
|
104
|
+
"""
|
|
105
|
+
new_text = remove_tag_marker(note.text or "", name)
|
|
106
|
+
if new_text is None:
|
|
107
|
+
raise TagMarkerNotFound(f"no #{name} marker in the note text")
|
|
108
|
+
actions.add_text(note.id, new_text, mode=TextMode.REPLACE_ALL.value)
|
|
109
|
+
return _confirmed(db, note.id, "untag", lambda n: not n.has_tag(name))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def attach_file(db: BearDB, note: Note, filename: str, file_b64: str) -> Note:
|
|
113
|
+
before = len(note.attachments)
|
|
114
|
+
actions.add_file(note.id, filename, file_b64)
|
|
115
|
+
return _confirmed(db, note.id, "attach", lambda n: len(n.attachments) > before)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def trash(db: BearDB, note: Note) -> Note:
|
|
119
|
+
actions.trash_note(note.id)
|
|
120
|
+
return _confirmed(db, note.id, "trash", lambda n: n.trashed)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def archive(db: BearDB, note: Note) -> Note:
|
|
124
|
+
actions.archive_note(note.id)
|
|
125
|
+
return _confirmed(db, note.id, "archive", lambda n: n.archived)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def rename_tag(db: BearDB, name: str, new_name: str) -> None:
|
|
129
|
+
"""Rename a tag across all notes; verified via the tag list."""
|
|
130
|
+
actions.rename_tag(name, new_name)
|
|
131
|
+
if not actions.wait_for(
|
|
132
|
+
lambda: new_name.lower() in {t.lower() for t, _ in db.list_tags(include_empty=True)},
|
|
133
|
+
timeout=VERIFY_TIMEOUT,
|
|
134
|
+
):
|
|
135
|
+
raise BearWriteError(f"tag {name!r} was not renamed")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def delete_tag(db: BearDB, name: str) -> None:
|
|
139
|
+
"""Delete a tag from every note; verified by its note count reaching zero.
|
|
140
|
+
|
|
141
|
+
Bear keeps the empty tag row behind, so absence is not the signal.
|
|
142
|
+
"""
|
|
143
|
+
actions.delete_tag(name)
|
|
144
|
+
|
|
145
|
+
def gone() -> bool:
|
|
146
|
+
counts = {t.lower(): c for t, c in db.list_tags(include_empty=True)}
|
|
147
|
+
return counts.get(name.lower(), 0) == 0
|
|
148
|
+
|
|
149
|
+
if not actions.wait_for(gone, timeout=VERIFY_TIMEOUT):
|
|
150
|
+
raise BearWriteError(f"tag {name!r} was not deleted")
|
bearkit/py.typed
ADDED
|
File without changes
|
bearkit/search.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Fuzzy search over notes using rapidfuzz."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from rapidfuzz import fuzz, utils
|
|
9
|
+
|
|
10
|
+
from bearkit.db import Note
|
|
11
|
+
|
|
12
|
+
# Where the match landed determines its weight: a title hit beats an equally
|
|
13
|
+
# good tag hit, which beats a body hit.
|
|
14
|
+
TITLE_WEIGHT = 1.0
|
|
15
|
+
TAG_WEIGHT = 0.95
|
|
16
|
+
BODY_WEIGHT = 0.9
|
|
17
|
+
|
|
18
|
+
SNIPPET_LENGTH = 70
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SearchResult:
|
|
23
|
+
note: Note
|
|
24
|
+
snippet: str
|
|
25
|
+
score: float | None = None # None for naive (substring) matches
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def naive_search(notes: list[Note], query: str) -> list[SearchResult]:
|
|
29
|
+
"""Case-insensitive substring search over titles, tags, and text.
|
|
30
|
+
|
|
31
|
+
Input order (most recently modified first) is preserved.
|
|
32
|
+
"""
|
|
33
|
+
needle = query.lower()
|
|
34
|
+
results = []
|
|
35
|
+
for note in notes:
|
|
36
|
+
title_hit = needle in note.title.lower()
|
|
37
|
+
tag_hit = any(needle in tag.lower() for tag in note.tags)
|
|
38
|
+
body_line = next(
|
|
39
|
+
(line.strip() for line in (note.text or "").splitlines() if needle in line.lower()),
|
|
40
|
+
"",
|
|
41
|
+
)
|
|
42
|
+
if not (title_hit or tag_hit or body_line):
|
|
43
|
+
continue
|
|
44
|
+
snippet = _trim_snippet(body_line, query) if body_line and not (title_hit or tag_hit) else ""
|
|
45
|
+
results.append(SearchResult(note=note, snippet=snippet))
|
|
46
|
+
return results
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fuzzy_score(processed_query: str, target: str) -> float:
|
|
50
|
+
"""Direction-aware scoring: the query must always be the needle.
|
|
51
|
+
|
|
52
|
+
partial_ratio slides the shorter string over the longer one, so a trivially
|
|
53
|
+
short target ("Bindi") would score ~80 against a long query. Only allow the
|
|
54
|
+
sliding when the target is at least query-sized; otherwise use plain ratio,
|
|
55
|
+
which penalizes the missing content.
|
|
56
|
+
"""
|
|
57
|
+
processed_target = utils.default_process(target)
|
|
58
|
+
if not processed_query or not processed_target:
|
|
59
|
+
return 0.0
|
|
60
|
+
if len(processed_target) >= len(processed_query):
|
|
61
|
+
return fuzz.partial_ratio(processed_query, processed_target)
|
|
62
|
+
return fuzz.ratio(processed_query, processed_target)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _body_match(query: str, text: str | None) -> tuple[float, str]:
|
|
66
|
+
"""Score the query against the full note text; return (score, matching line).
|
|
67
|
+
|
|
68
|
+
partial_ratio must slide the query over the text, never the reverse: with a
|
|
69
|
+
short haystack the needle/haystack roles flip and trivial strings score ~90.
|
|
70
|
+
"""
|
|
71
|
+
processed_query = utils.default_process(query)
|
|
72
|
+
processed_text = utils.default_process(text or "")
|
|
73
|
+
if not processed_query or not processed_text:
|
|
74
|
+
return 0.0, ""
|
|
75
|
+
if len(processed_text) < len(processed_query):
|
|
76
|
+
return fuzz.ratio(processed_query, processed_text), (text or "").strip().splitlines()[0]
|
|
77
|
+
|
|
78
|
+
alignment = fuzz.partial_ratio_alignment(processed_query, processed_text)
|
|
79
|
+
if alignment is None:
|
|
80
|
+
return 0.0, ""
|
|
81
|
+
# default_process keeps positions 1:1 (non-alnum become spaces) except for
|
|
82
|
+
# the leading strip, so shift by where the first alphanumeric char was.
|
|
83
|
+
first_alnum = re.search(r"[^\W_]", text or "")
|
|
84
|
+
pos = alignment.dest_start + (first_alnum.start() if first_alnum else 0)
|
|
85
|
+
line_start = (text or "").rfind("\n", 0, pos) + 1
|
|
86
|
+
line_end = (text or "").find("\n", pos)
|
|
87
|
+
line = (text or "")[line_start : line_end if line_end >= 0 else None].strip()
|
|
88
|
+
return alignment.score, line
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _trim_snippet(line: str, query: str) -> str:
|
|
92
|
+
if len(line) <= SNIPPET_LENGTH:
|
|
93
|
+
return line
|
|
94
|
+
# Center the snippet on the first query word that occurs literally, if any.
|
|
95
|
+
lowered = line.lower()
|
|
96
|
+
pos = next((p for w in query.lower().split() if (p := lowered.find(w)) >= 0), 0)
|
|
97
|
+
start = max(0, min(pos - SNIPPET_LENGTH // 3, len(line) - SNIPPET_LENGTH))
|
|
98
|
+
end = start + SNIPPET_LENGTH
|
|
99
|
+
prefix = "…" if start > 0 else ""
|
|
100
|
+
suffix = "…" if end < len(line) else ""
|
|
101
|
+
return f"{prefix}{line[start:end].strip()}{suffix}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def search_notes(notes: list[Note], query: str, min_score: float = 60.0) -> list[SearchResult]:
|
|
105
|
+
"""Score every note against the query; return matches sorted best-first."""
|
|
106
|
+
processed_query = utils.default_process(query)
|
|
107
|
+
results = []
|
|
108
|
+
for note in notes:
|
|
109
|
+
title_score = _fuzzy_score(processed_query, note.title) * TITLE_WEIGHT
|
|
110
|
+
tag_score = max(_fuzzy_score(processed_query, tag) for tag in note.tags) * TAG_WEIGHT if note.tags else 0.0
|
|
111
|
+
body_raw, body_line = _body_match(query, note.text)
|
|
112
|
+
body_score = body_raw * BODY_WEIGHT
|
|
113
|
+
|
|
114
|
+
score = max(title_score, tag_score, body_score)
|
|
115
|
+
if score < min_score:
|
|
116
|
+
continue
|
|
117
|
+
snippet = _trim_snippet(body_line, query) if body_score >= max(title_score, tag_score) else ""
|
|
118
|
+
results.append(SearchResult(note=note, score=score, snippet=snippet))
|
|
119
|
+
|
|
120
|
+
results.sort(key=lambda r: r.score or 0.0, reverse=True)
|
|
121
|
+
return results
|
bearkit/secrets.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Secret detection over note text, powered by detect-secrets (Yelp) plus
|
|
2
|
+
note-specific detectors.
|
|
3
|
+
|
|
4
|
+
A first line of defense before notes leave the machine via export. Everything
|
|
5
|
+
runs offline — note content is never sent anywhere. detect-secrets provides
|
|
6
|
+
the format detectors (AWS, GitHub, Slack, Stripe, private keys, JWTs, keyword
|
|
7
|
+
assignments, …); two gaps matter for prose notes and are covered here:
|
|
8
|
+
|
|
9
|
+
- detect-secrets' entropy plugins only match *quoted* strings, which pasted
|
|
10
|
+
keys in notes never are — so unquoted token runs get their own entropy scan.
|
|
11
|
+
- Credentials in notes often sit on the line *below* a label ("Client
|
|
12
|
+
secret:" then the value, often inside a code fence) — a look-ahead rule
|
|
13
|
+
catches those.
|
|
14
|
+
|
|
15
|
+
Secrets written as prose are still undetectable; for deeper auditing run
|
|
16
|
+
gitleaks over an --allow-secrets export.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from detect_secrets.core.plugins.util import get_mapping_from_secret_type_to_class
|
|
27
|
+
from detect_secrets.filters import heuristic
|
|
28
|
+
from detect_secrets.plugins.base import BasePlugin
|
|
29
|
+
|
|
30
|
+
from bearkit.db import Note
|
|
31
|
+
|
|
32
|
+
# IPPublicDetector: an IP address in a note is not a credential.
|
|
33
|
+
# The entropy plugins only match quoted strings — replaced by _scan_entropy.
|
|
34
|
+
_EXCLUDED_PLUGINS = {"IPPublicDetector", "Base64HighEntropyString", "HexHighEntropyString"}
|
|
35
|
+
|
|
36
|
+
_BASE64_ENTROPY_LIMIT = 4.5 # detect-secrets' default
|
|
37
|
+
_HEX_ENTROPY_LIMIT = 3.5 # above default (3.0): notes are full of UUID/hash fragments
|
|
38
|
+
|
|
39
|
+
# A contiguous credential-looking run: base64/urlsafe chars plus the '.' that
|
|
40
|
+
# joins JWT segments. 20+ chars keeps ordinary words and short ids out.
|
|
41
|
+
_TOKEN_RUN = re.compile(r"[A-Za-z0-9+/\-_=.]{20,}")
|
|
42
|
+
_HEX_ONLY = re.compile(r"^[0-9a-fA-F]+$")
|
|
43
|
+
|
|
44
|
+
# "Client secret:" / "Access token" style label with nothing after it — the
|
|
45
|
+
# value is expected on the next content line (code fences skipped).
|
|
46
|
+
_LABEL_ONLY = re.compile(
|
|
47
|
+
r"(?i)^\s*\**(?:client\s+secret|secret\s+access\s+key|access\s+token|auth\s+token|refresh\s+token"
|
|
48
|
+
r"|api\s*-?\s*key|access\s+key|private\s+key|secret|password|passwd|pwd|token)\s*\**\s*[:=]?\s*$"
|
|
49
|
+
)
|
|
50
|
+
_FENCE = re.compile(r"^\s*(?:```|~~~)")
|
|
51
|
+
_TRIM_PUNCT = "()[]{}<>«»\"'`,;|"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_plugins() -> list[BasePlugin]:
|
|
55
|
+
return [cls() for cls in get_mapping_from_secret_type_to_class().values() if cls.__name__ not in _EXCLUDED_PLUGINS]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _entropy(value: str) -> float:
|
|
59
|
+
counts = {char: value.count(char) for char in set(value)}
|
|
60
|
+
return -sum(n / len(value) * math.log2(n / len(value)) for n in counts.values())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_false_positive(value: str, line: str) -> bool:
|
|
64
|
+
return (
|
|
65
|
+
heuristic.is_potential_uuid(value)
|
|
66
|
+
or heuristic.is_sequential_string(value)
|
|
67
|
+
or heuristic.is_templated_secret(value)
|
|
68
|
+
or heuristic.is_likely_id_string(value, line)
|
|
69
|
+
or heuristic.is_not_alphanumeric_string(value)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _expand_to_token(line: str, value: str) -> str:
|
|
74
|
+
"""Grow a detected value to the full unbroken token around it.
|
|
75
|
+
|
|
76
|
+
Format detectors can match only part of a longer credential (a JWT's
|
|
77
|
+
first two segments, say); redacting the partial match would leave the
|
|
78
|
+
rest behind.
|
|
79
|
+
"""
|
|
80
|
+
start = line.find(value)
|
|
81
|
+
if start < 0:
|
|
82
|
+
return value
|
|
83
|
+
end = start + len(value)
|
|
84
|
+
while start > 0 and not line[start - 1].isspace():
|
|
85
|
+
start -= 1
|
|
86
|
+
while end < len(line) and not line[end].isspace():
|
|
87
|
+
end += 1
|
|
88
|
+
return line[start:end].strip(_TRIM_PUNCT)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _in_url(line: str, start: int) -> bool:
|
|
92
|
+
"""Whether the token starting at `start` is part of a URL.
|
|
93
|
+
|
|
94
|
+
Long ids inside links (Google Docs, Notion, …) are high-entropy but are
|
|
95
|
+
shareable references, not credentials — flagging them would drown real
|
|
96
|
+
findings. Token-bearing URLs with known formats (Slack webhooks, …) are
|
|
97
|
+
still caught by their format detectors.
|
|
98
|
+
"""
|
|
99
|
+
token_start = start
|
|
100
|
+
while token_start > 0 and not line[token_start - 1].isspace():
|
|
101
|
+
token_start -= 1
|
|
102
|
+
return "://" in line[token_start : start + 3]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scan_entropy(line: str) -> list[str]:
|
|
106
|
+
hits = []
|
|
107
|
+
for match in _TOKEN_RUN.finditer(line):
|
|
108
|
+
if _in_url(line, match.start()):
|
|
109
|
+
continue
|
|
110
|
+
value = match.group().strip(_TRIM_PUNCT + ".=")
|
|
111
|
+
limit = _HEX_ENTROPY_LIMIT if _HEX_ONLY.fullmatch(value) else _BASE64_ENTROPY_LIMIT
|
|
112
|
+
if len(value) >= 20 and _entropy(value) >= limit:
|
|
113
|
+
hits.append(match.group())
|
|
114
|
+
return hits
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _labeled_value(lines: list[str], label_index: int) -> tuple[int, str] | None:
|
|
118
|
+
"""The first content line after a bare credential label, if it looks like a value."""
|
|
119
|
+
for offset in range(1, 4):
|
|
120
|
+
index = label_index + offset
|
|
121
|
+
if index >= len(lines):
|
|
122
|
+
return None
|
|
123
|
+
candidate = lines[index].strip()
|
|
124
|
+
if not candidate or _FENCE.match(candidate):
|
|
125
|
+
continue
|
|
126
|
+
candidate = candidate.strip(_TRIM_PUNCT)
|
|
127
|
+
if " " not in candidate and 8 <= len(candidate) <= 200:
|
|
128
|
+
return index, candidate
|
|
129
|
+
return None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class SecretFinding:
|
|
135
|
+
note_id: str
|
|
136
|
+
note_title: str
|
|
137
|
+
rule: str
|
|
138
|
+
line: int
|
|
139
|
+
excerpt: str
|
|
140
|
+
secret: str # raw value, for redaction only — never print this
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _redact(value: str) -> str:
|
|
144
|
+
"""Show enough to locate the secret in the note, never the secret itself."""
|
|
145
|
+
return f"{value[:8]}…" if len(value) > 12 else "…"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _scan_note(note: Note, plugins: list[BasePlugin]) -> list[SecretFinding]:
|
|
149
|
+
lines = list((note.text or "").splitlines())
|
|
150
|
+
seen: set[str] = set()
|
|
151
|
+
findings: list[SecretFinding] = []
|
|
152
|
+
|
|
153
|
+
def add(lineno: int, value: str, rule: str) -> None:
|
|
154
|
+
if value and value not in seen and not _is_false_positive(value, lines[lineno - 1]):
|
|
155
|
+
seen.add(value)
|
|
156
|
+
findings.append(
|
|
157
|
+
SecretFinding(
|
|
158
|
+
note_id=note.id,
|
|
159
|
+
note_title=note.title,
|
|
160
|
+
rule=rule,
|
|
161
|
+
line=lineno,
|
|
162
|
+
excerpt=_redact(value),
|
|
163
|
+
secret=value,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
for lineno, line in enumerate(lines, start=1):
|
|
168
|
+
for plugin in plugins:
|
|
169
|
+
# The yaml filetype hint makes KeywordDetector accept unquoted
|
|
170
|
+
# `password: value` assignments, which is how notes write them.
|
|
171
|
+
for secret in plugin.analyze_line(filename="note.yaml", line=line, line_number=lineno):
|
|
172
|
+
add(lineno, _expand_to_token(line, secret.secret_value or ""), secret.type)
|
|
173
|
+
for value in _scan_entropy(line):
|
|
174
|
+
add(lineno, value, "High Entropy String")
|
|
175
|
+
if _LABEL_ONLY.match(line):
|
|
176
|
+
if labeled := _labeled_value(lines, lineno - 1):
|
|
177
|
+
value_index, value = labeled
|
|
178
|
+
add(value_index + 1, value, "Labeled Credential")
|
|
179
|
+
return findings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _replace(text: str, secrets: dict[str, str]) -> str:
|
|
183
|
+
# Longest first, in case one detected value contains another.
|
|
184
|
+
for value in sorted(secrets, key=len, reverse=True):
|
|
185
|
+
text = text.replace(value, f"[redacted: {secrets[value]}]")
|
|
186
|
+
return text
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class ScanReport:
|
|
191
|
+
"""The outcome of a scan: findings, plus the redaction that follows.
|
|
192
|
+
|
|
193
|
+
Iterable and truthy on findings; per-note access via `has`/`for_note`;
|
|
194
|
+
`redact`/`redact_text` replace each detected value with a
|
|
195
|
+
`[redacted: <rule>]` placeholder.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
findings: list[SecretFinding]
|
|
199
|
+
|
|
200
|
+
def __iter__(self) -> Iterator[SecretFinding]:
|
|
201
|
+
return iter(self.findings)
|
|
202
|
+
|
|
203
|
+
def __len__(self) -> int:
|
|
204
|
+
return len(self.findings)
|
|
205
|
+
|
|
206
|
+
def __bool__(self) -> bool:
|
|
207
|
+
return bool(self.findings)
|
|
208
|
+
|
|
209
|
+
def has(self, note_id: str) -> bool:
|
|
210
|
+
return any(f.note_id == note_id for f in self.findings)
|
|
211
|
+
|
|
212
|
+
def for_note(self, note_id: str) -> dict[str, str]:
|
|
213
|
+
"""The note's secret values mapped to the rule that found them."""
|
|
214
|
+
values: dict[str, str] = {}
|
|
215
|
+
for finding in self.findings:
|
|
216
|
+
if finding.note_id == note_id and finding.secret:
|
|
217
|
+
values.setdefault(finding.secret, finding.rule)
|
|
218
|
+
return values
|
|
219
|
+
|
|
220
|
+
def redact_text(self, text: str) -> str:
|
|
221
|
+
"""The text with every detected value replaced by its placeholder."""
|
|
222
|
+
secrets: dict[str, str] = {}
|
|
223
|
+
for finding in self.findings:
|
|
224
|
+
if finding.secret:
|
|
225
|
+
secrets.setdefault(finding.secret, finding.rule)
|
|
226
|
+
return _replace(text, secrets)
|
|
227
|
+
|
|
228
|
+
def redact(self, note: Note) -> str:
|
|
229
|
+
"""The note's text, redacted ("" for encrypted notes)."""
|
|
230
|
+
return _replace(note.text or "", self.for_note(note.id))
|
|
231
|
+
|
|
232
|
+
def notes_affected(self) -> int:
|
|
233
|
+
return len({f.note_id for f in self.findings})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def scan_notes(notes: list[Note]) -> ScanReport:
|
|
237
|
+
"""Scan every note; the report carries the findings and can redact."""
|
|
238
|
+
plugins = _build_plugins()
|
|
239
|
+
findings: list[SecretFinding] = []
|
|
240
|
+
for note in notes:
|
|
241
|
+
findings.extend(_scan_note(note, plugins))
|
|
242
|
+
return ScanReport(findings)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bearkit
|
|
3
|
+
Version: 1.6.0
|
|
4
|
+
Summary: Python toolkit for the Bear notes app — read, search, secret detection, and verified writes
|
|
5
|
+
Keywords: bear,notes,markdown,macos
|
|
6
|
+
Author: Michel Tricot
|
|
7
|
+
Author-email: Michel Tricot <michel.tricot@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Dist: detect-secrets>=1.5.0
|
|
15
|
+
Requires-Dist: rapidfuzz>=3.14.5
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Project-URL: Documentation, https://github.com/michel-tricot/bearcli/blob/main/docs/BEARKIT.md
|
|
18
|
+
Project-URL: Homepage, https://michel-tricot.github.io/bearcli/
|
|
19
|
+
Project-URL: Repository, https://github.com/michel-tricot/bearcli
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# 🐻 `bearkit`
|
|
23
|
+
|
|
24
|
+
The Python toolkit for the [Bear](https://bear.app) notes app: read your
|
|
25
|
+
notes, write them through the Bear app with verification, search them, and
|
|
26
|
+
detect secrets - all offline. macOS only.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from bearkit import Bear
|
|
30
|
+
|
|
31
|
+
with Bear() as bear:
|
|
32
|
+
for note in bear.list_notes(tag="work", limit=10):
|
|
33
|
+
print(note.title)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Full API reference:
|
|
37
|
+
[docs/BEARKIT.md](https://github.com/michel-tricot/bearcli/blob/main/docs/BEARKIT.md).
|
|
38
|
+
`bearkit` powers [`bearcli`](https://pypi.org/project/bearcli/), the CLI and
|
|
39
|
+
terminal UI; install that for the full tool.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bearkit/__init__.py,sha256=9gCu5LUwl2eO_pmFPCsuwLm9p2lKXrKKPNl5ar8NM88,1611
|
|
2
|
+
bearkit/actions.py,sha256=nKhu4iUjS6-SAvsJKvoTqPRa0mj9lD9zKAOFJKb9OZY,2430
|
|
3
|
+
bearkit/bear.py,sha256=I5ZS8ZI07UDMMyflupFcl6L2XSeZBv_fHtqUYGPtkV0,6555
|
|
4
|
+
bearkit/db.py,sha256=WvXRuB4fHDZIlXH2NgHVo3HD8MzOMYzByG3nasYUeGQ,12946
|
|
5
|
+
bearkit/markdown.py,sha256=tTviQilh8iCMubU3Xif-yfx1z-OP3YMzbWWAOE53y3g,1734
|
|
6
|
+
bearkit/ops.py,sha256=pzmQDyOEhY-vYup7QYyVxlTJG4CCvH-xxLY_v4_GsgM,5492
|
|
7
|
+
bearkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
bearkit/search.py,sha256=KRvlBpd9jYpUbbnnT17kn4NHueR280lURxMgzICH1ZY,4814
|
|
9
|
+
bearkit/secrets.py,sha256=0XaI2o49sOo3sCS6lDIT_LmkLR3czx622CTu3AnIR6w,9077
|
|
10
|
+
bearkit-1.6.0.dist-info/licenses/LICENSE,sha256=6GQ08_KAylombOwcGnemMQM8Ai39XsWdW25vHPE6v_s,1070
|
|
11
|
+
bearkit-1.6.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
12
|
+
bearkit-1.6.0.dist-info/METADATA,sha256=st_vccwU2zCjvw06qXvQgEqTFA7KYrlRB1TbN57Px9U,1453
|
|
13
|
+
bearkit-1.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michel Tricot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|