ideadump 0.0.2__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.
ideadump/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """ideadump: a place to dump ideas, research them a little, and find them again.
2
+
3
+ Capture an idea in the owner's own words, attach research notes (a light pass at
4
+ capture, a deep one when it looks promising), tag and link ideas, and ask which ideas
5
+ relate to a query or to a page describing recent work. Ideas live in a ``dol`` store,
6
+ by default a local folder, optionally a checkout of a private git repository.
7
+
8
+ >>> from ideadump import add, find
9
+ >>> files = {}
10
+ >>> _ = add("Offline speech to text on the phone", tags="audio", files=files)
11
+ >>> find("speech recognition", files=files)["hits"][0]["tags"]
12
+ ['audio']
13
+
14
+ The command line (``ideadump --help``) and the agent skill shipped in
15
+ ``ideadump/data/skills/ideadump`` drive the same verbs.
16
+ """
17
+
18
+ from ideadump.base import IdeadumpError
19
+ from ideadump.store import data_dir, text_files
20
+ from ideadump.sync import sync, sync_init, sync_status
21
+ from ideadump.tools import (
22
+ TOOLS,
23
+ add,
24
+ find,
25
+ link,
26
+ ls,
27
+ note,
28
+ reindex,
29
+ show,
30
+ update,
31
+ where,
32
+ )
33
+
34
+ __all__ = [
35
+ "IdeadumpError",
36
+ "TOOLS",
37
+ "add",
38
+ "data_dir",
39
+ "find",
40
+ "link",
41
+ "ls",
42
+ "note",
43
+ "reindex",
44
+ "show",
45
+ "sync",
46
+ "sync_init",
47
+ "sync_status",
48
+ "text_files",
49
+ "update",
50
+ "where",
51
+ ]
ideadump/__main__.py ADDED
@@ -0,0 +1,73 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``ideadump`` on the command line: every verb of :data:`ideadump.tools.TOOLS`.
3
+
4
+ ideadump add "the owner's words, verbatim" --title "Short title" --tags "a, b" --source my-session
5
+ ideadump note <id> --file research.md --depth light
6
+ ideadump find "offline speech to text" # or --file recent-work.md
7
+ ideadump ls --status promising
8
+ ideadump show <id>
9
+ ideadump sync
10
+
11
+ ``--json`` as the last argument prints the whole result instead of its text. Exit status is 0 on
12
+ success and 1 on an error, whose message says what to do next.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import json
19
+ import sys
20
+
21
+ import cw
22
+
23
+ from ideadump import tools
24
+ from ideadump.base import IdeadumpError
25
+
26
+
27
+ def _command(func):
28
+ func = tools.without(func)
29
+
30
+ @functools.wraps(func)
31
+ def command(*args, **kwargs):
32
+ try:
33
+ return func(*args, **kwargs)
34
+ except IdeadumpError as error:
35
+ raise cw.CommandError(str(error)) from error
36
+
37
+ return command
38
+
39
+
40
+ def _egress(as_json):
41
+ def egress(result, *, out, err):
42
+ if as_json or not isinstance(result, dict):
43
+ print(
44
+ json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out
45
+ )
46
+ else:
47
+ print(result.get("text", ""), file=out)
48
+ return 0
49
+
50
+ return egress
51
+
52
+
53
+ def main(argv=None):
54
+ """Run the command line. The ``ideadump`` console script."""
55
+ for stream in (sys.stdout, sys.stderr):
56
+ getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
57
+ argv = list(sys.argv[1:] if argv is None else argv)
58
+ as_json = bool(argv) and argv[-1] == "--json"
59
+ if as_json:
60
+ argv = argv[:-1]
61
+ commands = {f.__name__.replace("_", "-"): _command(f) for f in tools.TOOLS}
62
+ code = cw.dispatch(
63
+ commands,
64
+ argv,
65
+ prog="ideadump",
66
+ convention=cw.MODERN,
67
+ egress=_egress(as_json),
68
+ )
69
+ raise SystemExit(code)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
ideadump/base.py ADDED
@@ -0,0 +1,38 @@
1
+ """Errors and constants shared across ideadump.
2
+
3
+ An idea is one folder in the store, ``ideas/<id>/``, holding its record
4
+ (``idea.json``, the single source of truth), a rendered ``README.md`` that a person
5
+ reads on GitHub or a phone, and its research notes under ``research/``. Nothing here
6
+ depends on any other module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ APP_NAME = "ideadump"
12
+ DATA_DIR_ENVVAR = "IDEADUMP_DATA_DIR"
13
+
14
+ IDEAS_DIR = "ideas"
15
+ RECORD_FILE = "idea.json"
16
+ README_FILE = "README.md"
17
+ RESEARCH_DIR = "research"
18
+
19
+ #: Where an idea stands. ``new`` on capture; the owner (or an agent relaying the
20
+ #: owner's words) moves it on. ``promising`` is the cue for a deeper research pass.
21
+ STATUSES = ("new", "promising", "parked", "pursued", "dropped")
22
+
23
+ #: How deep a research note went. ``light`` is the pass made at capture (minutes, a
24
+ #: few sources); ``deep`` is a full ``do-research``-style report.
25
+ DEPTHS = ("light", "deep")
26
+
27
+
28
+ class IdeadumpError(Exception):
29
+ """An error whose message says what to do next."""
30
+
31
+
32
+ def record_key(idea_id: str) -> str:
33
+ """The store key of an idea's record.
34
+
35
+ >>> record_key("2026-11-14-an-idea")
36
+ 'ideas/2026-11-14-an-idea/idea.json'
37
+ """
38
+ return f"{IDEAS_DIR}/{idea_id}/{RECORD_FILE}"
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: ideadump
3
+ description: Capture an idea the owner tells you, in their own words, research it a little (more if it looks promising), tag and link it, and find it again later, with the ideadump command. Use whenever the owner shares an idea unrelated to the current task or asks you to keep one ("I have an idea", "idea:", "jot this down", "dump this idea", "note this idea for later", "remind me of this idea"), asks about their ideas ("what ideas do I have about X", "list my ideas", "show the idea about Y", "research that idea deeper", "that one is promising", "park that idea"), or asks whether anything they have been working on relates to their ideas ("is any of my recent work related to my ideas", "anything in my ideas worth doing now"), typically during spare token budget. Ideas are stored in a local folder or a private git repository, never in the current project.
4
+ license: MIT
5
+ metadata:
6
+ audience: users
7
+ ---
8
+
9
+ # ideadump: a place to dump ideas, and find them again
10
+
11
+ The owner tells you an idea in passing, often in a session that has nothing to do with it, often dictated. Your job: keep it **in their words**, make it findable, give it a first look, and get out of the way. One command captures it; everything else is optional and cheap.
12
+
13
+ **First, is it for the current task?** An idea about the work in front of you ("I have an idea: do X this way") is an instruction: act on it, do not dump it. Dump only ideas meant for later or for something else, or when the owner says to keep it.
14
+
15
+ Ideas live in ideadump's data folder (`ideadump where`), never in the project you are working in. If that folder is a checkout of a private repository, `ideadump sync` pushes; otherwise it is local only, which is fine.
16
+
17
+ ## Capture (always)
18
+
19
+ ```bash
20
+ ideadump add "<the owner's words, verbatim>" --title "<short title>" --summary "<your clean reading>" --tags "<tag>, <tag>" --source "<this session's name>"
21
+ ```
22
+
23
+ - **Words verbatim**, dictation slips included. Never "fix" them in `words`; put your reading in `--summary`, and when you resolved a slip say so there ("'attic system' read as 'agentic system'"). If a slip changes the meaning and you cannot tell, ask one short question.
24
+ - **Title**: five words or fewer, the thing itself ("Idea dump tool", not "An idea about ideas").
25
+ - **Tags**: two to four, lowercase, reused from existing ones where they fit: `ideadump ls` shows the tags in use. Prefer the domain (`audio`, `fleet`, `writing`) and the kind (`tool`, `essay`, `research-question`, `business`).
26
+ - Then `ideadump find "<title and summary>"` (the new idea is among the hits; ignore its own id): if an earlier idea is the same or close, `ideadump link <id> <other> --why "<one line>"` (or tell the owner it is a duplicate and ask whether to merge).
27
+ - Reply to the owner in two lines at most: the id and title, and that a light look follows (or what it found).
28
+
29
+ **In a session that must keep its context small** (a lookout or switchboard session, or the middle of an unrelated task): run the `add` yourself, then hand the light research to a subagent with the id and these instructions, and carry on with what you were doing.
30
+
31
+ ## Light research (at capture, a few minutes)
32
+
33
+ Search with the field's generic terms, not the owner's sentences: their words stay in the store, and outside search services see only queries.
34
+
35
+ Answer four questions, with sources, in a short Markdown note:
36
+
37
+ 1. **What is this called?** The standard name for the idea or its field, if there is one.
38
+ 2. **What exists already?** Prior art: tools, libraries, papers, products; one line each with a link. If you can search the owner's own packages and skills (in their fleet: `ir discover packages "<two phrasings>"`, `ir discover skills ...`), say what they already have.
39
+ 3. **What is the crux?** The one thing that would make it hard, or the one thing that makes it easy.
40
+ 4. **Verdict**: `promising`, `worth a deeper look`, `exists already (use X)`, or `park`, with one sentence of why.
41
+
42
+ References Vancouver style (`[1]`, `[2]` with a REFERENCES list, `[name](url)` links). Then:
43
+
44
+ ```bash
45
+ ideadump note <id> --file <note.md> --depth light
46
+ ideadump update <id> --status promising # only if the verdict says so; parked/dropped likewise
47
+ ideadump sync
48
+ ```
49
+
50
+ Write the note in a temporary folder, not in the current project.
51
+
52
+ ## Deep research (on request, or when the owner agrees an idea is promising)
53
+
54
+ Load the `do-research` skill and run it on the idea at deep depth (its report shape, its Vancouver references). Store the report with `ideadump note <id> --file <report.md> --depth deep`, then `ideadump sync`. The report is as private as the idea. If the owner reads on a phone, give them a link only to a destination that is private and access-controlled (in their fleet, `annals publish`, whose links are private; or the idea's page in the private data repository). Never publish it anywhere public.
55
+
56
+ ## Finding ideas
57
+
58
+ ```bash
59
+ ideadump ls # newest first; --tag, --status, --limit
60
+ ideadump show <id or part of it> # the idea's page: words, summary, research, links
61
+ ideadump find "offline speech recognition" # ranked, with the words each hit shares with the query
62
+ ```
63
+
64
+ The data folder is browsable as it is: `README.md` at its root is the index (newest first, then by tag), and each `ideas/<id>/README.md` is an idea's page, so on GitHub the owner can read it from a phone.
65
+
66
+ ## "Is anything I have been working on related to my ideas?"
67
+
68
+ 1. Gather a page of text describing recent work into a temporary file: what the owner's sessions did lately (in their fleet: `crowsnest ledger` for the per-session ledgers, `ol` for open loops), recent issue and PR titles, recent commit subjects (`git log --since=7.days --format=%s` in the active repositories). A few hundred lines is fine.
69
+ 2. `ideadump find --file <that file> --limit 15`.
70
+ 3. The ranking is lexical, so **judge it**: read the top hits' pages (`ideadump show`), keep only those that really relate, and tell the owner, for each, which idea, which work, and the one thing the work could do for the idea (or the idea for the work). Say plainly when nothing relates.
71
+ 4. Record a real connection: `ideadump update <id> --add-tags <project>` or a line in a research note.
72
+
73
+ ## Other verbs
74
+
75
+ `ideadump update <id> --title/--summary/--tags/--add-tags/--status`, `ideadump link <a> <b> --why ...`, `ideadump reindex` (regenerate the pages), `ideadump sync-status`, `ideadump sync-init <owner>/<name>` (make the data folder a checkout of a private repository, created private if missing; an existing ideas folder is adopted). `--json` as the last argument of any verb prints the full result. Statuses: `new`, `promising`, `parked`, `pursued`, `dropped`.
76
+
77
+ ## Never
78
+
79
+ - Never edit `words`; never delete an idea or a note the owner did not ask you to delete.
80
+ - Never put idea text in the current project, an issue, or anywhere public. If ideadump refuses because the data folder is inside another git work tree, tell the owner; never set `IDEADUMP_ALLOW_INSIDE_WORK_TREE` yourself.
81
+ - Never run deep research unasked on every idea: a light look at capture, deeper only on request or agreement.
ideadump/render.py ADDED
@@ -0,0 +1,125 @@
1
+ """Markdown views of the records: one ``README.md`` per idea and an index at the root.
2
+
3
+ The records (``idea.json``) are the source of truth; these pages are regenerated from
4
+ them on every write, so a person browsing the store on GitHub (or a phone) sees each
5
+ idea's folder open on its page, and the root opens on the index.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable
13
+ from datetime import datetime, timezone
14
+
15
+ from ideadump.base import IDEAS_DIR, README_FILE
16
+
17
+ __all__ = ["idea_page", "index_page", "research_depth", "created_at"]
18
+
19
+
20
+ def research_depth(record: dict) -> str:
21
+ """How far an idea has been researched: ``deep``, ``light`` or ``-``."""
22
+ depths = {note.get("depth") for note in record.get("research", [])}
23
+ return "deep" if "deep" in depths else "light" if depths else "-"
24
+
25
+
26
+ def created_at(record: dict) -> datetime:
27
+ """When an idea was captured, comparable across machines in different time zones.
28
+
29
+ >>> a = {"created": "2026-11-14T23:30:00+01:00"}
30
+ >>> b = {"created": "2026-11-14T22:45:00+00:00"}
31
+ >>> created_at(a) < created_at(b)
32
+ True
33
+ """
34
+ try:
35
+ moment = datetime.fromisoformat(record.get("created", ""))
36
+ except ValueError:
37
+ return datetime.min.replace(tzinfo=timezone.utc)
38
+ return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
39
+
40
+
41
+ def _text(value: str) -> str:
42
+ """A title safe inside a Markdown table cell and link label."""
43
+ return re.sub(r"([|\[\]])", r"\\\1", value.replace("\n", " "))
44
+
45
+
46
+ def _tags(record: dict) -> str:
47
+ return ", ".join(f"`{t}`" for t in record.get("tags", [])) or "-"
48
+
49
+
50
+ def idea_page(record: dict, *, titles: dict[str, str] | None = None) -> str:
51
+ """The page of one idea: the owner's words, the summary, research and links.
52
+
53
+ >>> page = idea_page({"id": "x", "title": "T", "words": "w", "created": "2026-11-14"})
54
+ >>> page.splitlines()[0]
55
+ '# T'
56
+ """
57
+ titles = titles or {}
58
+ lines = [
59
+ f"# {record['title']}",
60
+ "",
61
+ f"`{record['id']}` · {record.get('status', 'new')} · captured {record.get('created', '')[:10]}"
62
+ + (f" from {record['source']}" if record.get("source") else "")
63
+ + f" · research: {research_depth(record)}",
64
+ "",
65
+ f"Tags: {_tags(record)}",
66
+ "",
67
+ ]
68
+ if record.get("summary"):
69
+ lines += ["## Summary", "", record["summary"].strip(), ""]
70
+ lines += ["## In the owner's words", ""]
71
+ lines += [
72
+ f"> {line}" if line else ">" for line in record["words"].strip().splitlines()
73
+ ]
74
+ lines.append("")
75
+ if record.get("research"):
76
+ lines += ["## Research", ""]
77
+ for note in record["research"]:
78
+ lines.append(
79
+ f"- [{note.get('title') or note['file']}]({note['file']}) ({note['depth']}, {note['date'][:10]})"
80
+ )
81
+ lines.append("")
82
+ if record.get("links"):
83
+ lines += ["## Related ideas", ""]
84
+ for link in record["links"]:
85
+ other = link["id"]
86
+ label = titles.get(other, other)
87
+ why = f": {link['why']}" if link.get("why") else ""
88
+ lines.append(f"- [{_text(label)}](../{other}/{README_FILE}){why}")
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def index_page(records: Iterable[dict]) -> str:
94
+ """The root index: every idea, newest first, then the ideas under each tag.
95
+
96
+ >>> print(index_page([]).splitlines()[0])
97
+ # Ideas
98
+ """
99
+ records = sorted(records, key=created_at, reverse=True)
100
+
101
+ def link(r):
102
+ return f"[{_text(r['title'])}]({IDEAS_DIR}/{r['id']}/{README_FILE})"
103
+
104
+ lines = [
105
+ "# Ideas",
106
+ "",
107
+ f"{len(records)} ideas. Generated by ideadump from the `idea.json` records; do not edit by hand.",
108
+ "",
109
+ "| Captured | Idea | Status | Research | Tags |",
110
+ "|---|---|---|---|---|",
111
+ ]
112
+ for r in records:
113
+ lines.append(
114
+ f"| {r.get('created', '')[:10]} | {link(r)} | {r.get('status', 'new')} | {research_depth(r)} | {_tags(r)} |"
115
+ )
116
+ by_tag = defaultdict(list)
117
+ for r in records:
118
+ for tag in r.get("tags", []):
119
+ by_tag[tag].append(r)
120
+ if by_tag:
121
+ lines += ["", "## By tag", ""]
122
+ for tag in sorted(by_tag):
123
+ lines.append(f"- **{tag}**: " + ", ".join(link(r) for r in by_tag[tag]))
124
+ lines.append("")
125
+ return "\n".join(lines)
ideadump/search.py ADDED
@@ -0,0 +1,99 @@
1
+ """Rank ideas against a query: BM25 over words, standard library only.
2
+
3
+ The query can be a few words ("offline speech to text") or a page of text describing
4
+ recent work (ledgers, issue titles, commit messages): long queries are the "is
5
+ anything I've been working on related to my ideas?" case. The scorer is a seam: any
6
+ ``score(query, docs) -> list[float]`` can be passed to :func:`rank`, for instance a
7
+ semantic scorer backed by the fleet's ``ir`` search.
8
+
9
+ >>> docs = {"a": "offline speech to text on a phone", "b": "a recipe for bread"}
10
+ >>> [hit["id"] for hit in rank("speech recognition offline", docs)]
11
+ ['a']
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ from collections import Counter
19
+ from collections.abc import Callable, Mapping, Sequence
20
+
21
+ __all__ = ["tokens", "bm25", "rank"]
22
+
23
+ _WORD = re.compile(r"[a-z0-9][a-z0-9+#.-]*[a-z0-9+#]|[a-z0-9]", re.IGNORECASE)
24
+
25
+ # Function words carry no topic; dropping them keeps a page-long query from matching
26
+ # every idea on "the" and "with".
27
+ _STOPWORDS = frozenset(
28
+ """a about above after again all also am an and any are as at be because been
29
+ before being below between both but by can could did do does doing down during
30
+ each few for from further had has have having he her here hers him his how i if
31
+ in into is it its itself just let like make me more most my no nor not now of off
32
+ on once only or other our out over own same she should so some such than that the
33
+ their them then there these they this those through to too under until up very
34
+ was we were what when where which while who whom why will with would you your
35
+ yours maybe really thing things want wanna gonna get got way use using one also""".split()
36
+ )
37
+
38
+
39
+ def tokens(text: str) -> list[str]:
40
+ """Lowercased content words of ``text``, with a light plural fold.
41
+
42
+ >>> tokens("The ideas, and an Idea!")
43
+ ['idea', 'idea']
44
+ """
45
+ out = []
46
+ for word in _WORD.findall(text.lower()):
47
+ if word in _STOPWORDS or len(word) < 2:
48
+ continue
49
+ if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
50
+ word = word[:-1]
51
+ out.append(word)
52
+ return out
53
+
54
+
55
+ def bm25(
56
+ query: str, docs: Sequence[str], *, k1: float = 1.5, b: float = 0.75
57
+ ) -> list[float]:
58
+ """BM25 score of each document for ``query`` (query terms counted once each)."""
59
+ doc_tokens = [tokens(d) for d in docs]
60
+ n = len(doc_tokens)
61
+ if not n:
62
+ return []
63
+ avg_len = sum(map(len, doc_tokens)) / n or 1.0
64
+ df = Counter(t for toks in doc_tokens for t in set(toks))
65
+ terms = set(tokens(query))
66
+ scores = []
67
+ for toks in doc_tokens:
68
+ tf = Counter(toks)
69
+ norm = k1 * (1 - b + b * len(toks) / avg_len)
70
+ score = 0.0
71
+ for t in terms & tf.keys():
72
+ idf = math.log(1 + (n - df[t] + 0.5) / (df[t] + 0.5))
73
+ score += idf * tf[t] * (k1 + 1) / (tf[t] + norm)
74
+ scores.append(score)
75
+ return scores
76
+
77
+
78
+ def rank(
79
+ query: str,
80
+ docs: Mapping[str, str],
81
+ *,
82
+ limit: int = 10,
83
+ score: Callable[[str, Sequence[str]], list[float]] = bm25,
84
+ ) -> list[dict]:
85
+ """The ids of ``docs`` that match ``query``, best first, with score and shared terms."""
86
+ ids = list(docs)
87
+ scores = score(query, [docs[i] for i in ids])
88
+ query_terms = set(tokens(query))
89
+ hits = [
90
+ {
91
+ "id": i,
92
+ "score": round(s, 3),
93
+ "terms": sorted(query_terms & set(tokens(docs[i])))[:12],
94
+ }
95
+ for i, s in zip(ids, scores)
96
+ if s > 0
97
+ ]
98
+ hits.sort(key=lambda h: -h["score"])
99
+ return hits[:limit]
ideadump/store.py ADDED
@@ -0,0 +1,148 @@
1
+ """Where ideas live: a ``MutableMapping`` of ``/``-separated path to text.
2
+
3
+ Every verb takes a ``files`` store, so the default local folder can be swapped for any
4
+ other ``dol`` store (or a plain ``dict`` in tests) without touching the rest. The
5
+ default is a folder, the data root:
6
+
7
+ 1. the ``data_dir`` argument, else
8
+ 2. ``$IDEADUMP_DATA_DIR``, else
9
+ 3. ``~/.local/share/ideadump`` (``$XDG_DATA_HOME`` and ``%LOCALAPPDATA%`` honoured).
10
+
11
+ To keep ideas in a private git repository, make the data root a checkout of it with
12
+ ``ideadump sync-init <owner>/<name>`` (see :mod:`ideadump.sync`); the store
13
+ itself does not change.
14
+
15
+ >>> data_dir("somewhere").is_absolute()
16
+ True
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import time
23
+ from collections.abc import Iterator, MutableMapping
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from ideadump.base import APP_NAME, DATA_DIR_ENVVAR, IdeadumpError
29
+
30
+ __all__ = ["data_dir", "text_files", "resolve_files", "writing"]
31
+
32
+
33
+ def data_dir(data_dir: str | os.PathLike | None = None) -> Path:
34
+ """The data root: the argument, else ``$IDEADUMP_DATA_DIR``, else the user data folder."""
35
+ if data_dir:
36
+ return Path(os.path.abspath(os.path.expanduser(str(data_dir))))
37
+ configured = os.environ.get(DATA_DIR_ENVVAR)
38
+ if configured:
39
+ path = Path(os.path.expanduser(configured))
40
+ if not path.is_absolute():
41
+ raise IdeadumpError(
42
+ f"${DATA_DIR_ENVVAR} must be an absolute path, got {configured!r}"
43
+ )
44
+ return path
45
+ if os.name == "nt": # pragma: no cover - exercised on Windows CI only
46
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
47
+ return Path(base) / APP_NAME
48
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
49
+ return Path(base) / APP_NAME
50
+
51
+
52
+ def text_files(root: str | os.PathLike) -> MutableMapping[str, str]:
53
+ """A ``dol`` files store under ``root``: ``/``-separated keys, UTF-8 text values.
54
+
55
+ Folders are made on write and never on read; a delete is permanent.
56
+ """
57
+ from dol import Files, mk_dirs_if_missing, wrap_kvs
58
+
59
+ root = str(root)
60
+ os.makedirs(root, exist_ok=True)
61
+ files = mk_dirs_if_missing(Files(root, delete_func=os.remove))
62
+ return wrap_kvs(
63
+ files,
64
+ key_of_id=lambda k: k.replace(os.sep, "/"),
65
+ id_of_key=lambda k: k.replace("/", os.sep),
66
+ obj_of_data=lambda b: b.decode("utf-8", errors="replace"),
67
+ data_of_obj=lambda s: s.encode("utf-8"),
68
+ )
69
+
70
+
71
+ def resolve_files(
72
+ files: MutableMapping[str, str] | None = None, *, data_dir_: Any = None
73
+ ) -> MutableMapping[str, str]:
74
+ """``files`` if given, else the text store over the data root."""
75
+ return text_files(data_dir(data_dir_)) if files is None else files
76
+
77
+
78
+ _LOCK_FILE = ".ideadump.lock"
79
+ _LOCK_TIMEOUT = 30.0 # seconds to wait for another writer
80
+ _LOCK_STALE = 120.0 # a lock older than this was left by a crashed process
81
+ _LOCK_POLL = 0.05
82
+
83
+
84
+ ALLOW_WORK_TREE_ENVVAR = "IDEADUMP_ALLOW_INSIDE_WORK_TREE"
85
+
86
+
87
+ def enclosing_work_tree(root: Path) -> Path | None:
88
+ """The git work tree ``root`` sits inside, when it is not a checkout of its own."""
89
+ if (root / ".git").exists():
90
+ return None
91
+ return next((p for p in root.parents if (p / ".git").exists()), None)
92
+
93
+
94
+ def _refuse_foreign_work_tree(root: Path) -> None:
95
+ enclosing = enclosing_work_tree(root)
96
+ if enclosing and not os.environ.get(ALLOW_WORK_TREE_ENVVAR):
97
+ raise IdeadumpError(
98
+ f"the data folder {root} is inside the git work tree {enclosing}, so a commit "
99
+ "there would carry the ideas. Point $IDEADUMP_DATA_DIR elsewhere, or, if that "
100
+ f"repository is private and meant for them, set ${ALLOW_WORK_TREE_ENVVAR}=1"
101
+ )
102
+
103
+
104
+ @contextmanager
105
+ def writing(
106
+ files: MutableMapping[str, str] | None = None,
107
+ *,
108
+ data_dir_: Any = None,
109
+ timeout: float = _LOCK_TIMEOUT,
110
+ ) -> Iterator[MutableMapping[str, str]]:
111
+ """The store, held exclusively while a verb reads, changes and rewrites it.
112
+
113
+ Several sessions may capture ideas at the same moment; each write verb reads every
114
+ record, picks a free id, and rewrites the index, so two of them interleaving would
115
+ lose an idea. For the default folder store this takes a lock file in the data root
116
+ (created exclusively, so it works on every platform); a store passed in by the
117
+ caller is the caller's to serialise.
118
+ """
119
+ if files is not None:
120
+ yield files
121
+ return
122
+ root = data_dir(data_dir_)
123
+ _refuse_foreign_work_tree(root)
124
+ root.mkdir(parents=True, exist_ok=True)
125
+ lock = root / _LOCK_FILE
126
+ deadline = time.monotonic() + timeout
127
+ while True:
128
+ try:
129
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
130
+ os.write(fd, str(os.getpid()).encode())
131
+ os.close(fd)
132
+ break
133
+ except FileExistsError:
134
+ try:
135
+ if time.time() - lock.stat().st_mtime > _LOCK_STALE:
136
+ lock.unlink(missing_ok=True)
137
+ continue
138
+ except FileNotFoundError:
139
+ continue
140
+ if time.monotonic() > deadline:
141
+ raise IdeadumpError(
142
+ f"another ideadump write holds {lock}; if no other ideadump is running, delete that file"
143
+ ) from None
144
+ time.sleep(_LOCK_POLL)
145
+ try:
146
+ yield text_files(root)
147
+ finally:
148
+ lock.unlink(missing_ok=True)