From c3687612ea149d4d26fbcd2d7b7ba28fc7335155 Mon Sep 17 00:00:00 2001 From: francesco Date: Tue, 27 Oct 2026 19:54:48 +0100 Subject: [PATCH 1/3] feat: dossiers start as named drafts and get a folder when saved New Dossier asks only for a name; the draft lives in the user data dir and is moved to ~/Documents/Dossiers/ (or a chosen folder) on Save or when the window closes. Saved dossiers show a .dossier launcher (registered MIME type, opens the app), sources/, knowledge/ and notes/; derived state moves to a hidden .dossier/. Legacy projects open unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + Makefile | 7 + .../flatpak/io.github.openapi.Gknowledge.yaml | 6 +- data/io.github.openapi.Gknowledge.desktop | 2 +- data/io.github.openapi.Gknowledge.mime.xml | 12 ++ gknowledge/app.py | 81 ++++---- gknowledge/config.py | 8 + gknowledge/core/extraction.py | 2 +- gknowledge/core/project.py | 186 +++++++++++++++--- gknowledge/mcp.py | 2 +- gknowledge/ui/window.py | 166 ++++++++++++++-- tests/test_core.py | 5 +- tests/test_dossier.py | 123 ++++++++++++ 13 files changed, 531 insertions(+), 79 deletions(-) create mode 100644 data/io.github.openapi.Gknowledge.mime.xml create mode 100644 tests/test_dossier.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4422d6..c720b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to gknowledge. Dates are release dates. +## Unreleased + +**Dossiers** +- Projects become **dossiers**. **New Dossier** asks only for a name: the dossier is a draft you can work in right away, and it gets a folder when you save it (**Save…**, Ctrl+S) or close the window. By default it is saved in `~/Documents/Dossiers/` (the localised Documents folder); you can pick another folder. Closing with a draft open offers to save it or keep it as a draft for next time. +- A saved dossier shows only what a person needs: a `.dossier` launcher file (double-click opens it in gknowledge), `sources/`, `knowledge/` and `notes/`. The database, caches and settings move to a hidden `.dossier/` folder, excluded from git by a generated `.gitignore`. +- Projects created with 0.3 or earlier open unchanged. + +**App** +- Preferences: detect a local Ollama server and pick one of its models (thanks @AdityaWagh06, #46). + ## 0.3.0 — 2026-10-19 **Wiki** diff --git a/Makefile b/Makefile index 80ca085..9dba6c2 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ DATADIR := $(PREFIX)/share APPSDIR := $(DATADIR)/applications ICONDIR := $(DATADIR)/icons/hicolor/scalable/apps METAINFODIR := $(DATADIR)/metainfo +MIMEDIR := $(DATADIR)/mime/packages INSTALL := install @@ -103,10 +104,14 @@ install: $(INSTALL) -m 644 data/icons/hicolor/scalable/apps/$(APP_ID).svg $(DESTDIR)$(ICONDIR)/$(APP_ID).svg $(INSTALL) -d $(DESTDIR)$(METAINFODIR) $(INSTALL) -m 644 data/$(APP_ID).metainfo.xml $(DESTDIR)$(METAINFODIR)/$(APP_ID).metainfo.xml + # .dossier launcher files open in gknowledge. + $(INSTALL) -d $(DESTDIR)$(MIMEDIR) + $(INSTALL) -m 644 data/$(APP_ID).mime.xml $(DESTDIR)$(MIMEDIR)/$(APP_ID).xml @# Refresh caches only for a live install; packages leave this to dpkg triggers. @if [ -z "$(DESTDIR)" ]; then \ update-desktop-database $(APPSDIR) 2>/dev/null || true; \ gtk-update-icon-cache -f -t $(DATADIR)/icons/hicolor 2>/dev/null || true; \ + update-mime-database $(DATADIR)/mime 2>/dev/null || true; \ fi @echo ">> Done. Make sure $(BINDIR) is on your PATH, then run: gknowledge" @@ -116,10 +121,12 @@ uninstall: rm -f $(DESTDIR)$(APPSDIR)/$(APP_ID).desktop rm -f $(DESTDIR)$(ICONDIR)/$(APP_ID).svg rm -f $(DESTDIR)$(METAINFODIR)/$(APP_ID).metainfo.xml + rm -f $(DESTDIR)$(MIMEDIR)/$(APP_ID).xml @# A stale icon cache would still point at the removed icon and leave a blank one. @if [ -z "$(DESTDIR)" ]; then \ update-desktop-database $(APPSDIR) 2>/dev/null || true; \ gtk-update-icon-cache -f -t $(DATADIR)/icons/hicolor 2>/dev/null || true; \ + update-mime-database $(DATADIR)/mime 2>/dev/null || true; \ fi @echo ">> Uninstalled gknowledge from $(PREFIX)" diff --git a/build-aux/flatpak/io.github.openapi.Gknowledge.yaml b/build-aux/flatpak/io.github.openapi.Gknowledge.yaml index 4c39821..4c51366 100644 --- a/build-aux/flatpak/io.github.openapi.Gknowledge.yaml +++ b/build-aux/flatpak/io.github.openapi.Gknowledge.yaml @@ -11,8 +11,10 @@ finish-args: - --socket=wayland - --socket=fallback-x11 - --device=dri - # No broad filesystem access: projects are chosen through Gtk.FileDialog, - # which goes through the document portal inside the sandbox. + # No broad filesystem access: new dossiers are saved by default in + # ~/Documents/Dossiers (localised Documents folder); any other folder is + # chosen through Gtk.FileDialog and the document portal. + - --filesystem=xdg-documents/Dossiers:create modules: # PDF import; pure Python, installed from the wheel without network access. diff --git a/data/io.github.openapi.Gknowledge.desktop b/data/io.github.openapi.Gknowledge.desktop index 3f773a0..d542774 100644 --- a/data/io.github.openapi.Gknowledge.desktop +++ b/data/io.github.openapi.Gknowledge.desktop @@ -9,7 +9,7 @@ Terminal=false Categories=GNOME;GTK;Office; Keywords=knowledge;wiki;notes;llm;research;rag; StartupNotify=true -MimeType=text/markdown;text/plain;application/pdf;text/html; +MimeType=application/x-dossier;text/markdown;text/plain;application/pdf;text/html; StartupWMClass=io.github.openapi.Gknowledge SingleMainWindow=true X-Flatpak-RenamedFrom=io.gknowledge.Gknowledge.desktop; diff --git a/data/io.github.openapi.Gknowledge.mime.xml b/data/io.github.openapi.Gknowledge.mime.xml new file mode 100644 index 0000000..0feaafd --- /dev/null +++ b/data/io.github.openapi.Gknowledge.mime.xml @@ -0,0 +1,12 @@ + + + + + Dossier + Dossier + + + + + + diff --git a/gknowledge/app.py b/gknowledge/app.py index 0d386f1..4e343d6 100644 --- a/gknowledge/app.py +++ b/gknowledge/app.py @@ -11,7 +11,7 @@ from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402 from . import APP_ID, APP_NAME, __version__, config -from .core.project import Project +from .core.project import Project, default_home from .ui.window import MainWindow @@ -23,8 +23,10 @@ def __init__(self): self._pending_open: str | None = None self._pending_import: list[str] = [] - self._add_action("new-project", self._on_new_project, "n") - self._add_action("open-project", self._on_open_project, "o") + self._add_action("new-dossier", self._on_new_dossier, "n") + self._add_action("open-dossier", self._on_open_dossier, "o") + self._add_action("save-dossier", self._on_save_dossier, "s") + self.lookup_action("save-dossier").set_enabled(False) self._add_action("preferences", self._on_preferences, "comma") self._add_action("about", self._on_about) self._add_action("quit", self._on_quit, "q") @@ -46,8 +48,8 @@ def do_activate(self): self.window = MainWindow(self) self.window.present() if self._pending_open: - self._open_path(self._pending_open) - self._pending_open = None + path, self._pending_open = self._pending_open, None + self.window.confirm_leave(lambda: self._open_path(path)) else: self._maybe_open_last() # Files handed over by the file manager ("Open With") are imported @@ -57,11 +59,11 @@ def do_activate(self): if self.window.project: self.window._do_import(paths) else: - self.window.toast("Open or create a project first, then import the files.") + self.window.toast("Open or create a dossier first, then import the files.") def do_open(self, files, n_files, hint): - # `gknowledge /path/to/project` opens a project; other files are - # imported as sources into the current (or last used) project. + # `gknowledge /path/to/dossier` (the folder or its .dossier launcher) + # opens it; other files are imported as sources into the current one. for gfile in files[:n_files]: path = gfile.get_path() if not path: @@ -91,47 +93,56 @@ def _maybe_open_last(self): def _open_path(self, path: str): try: project = Project.open(path) + path = str(project.root) except Exception as exc: # noqa: BLE001 - self.window.toast(f"Could not open project: {exc}") + self.window.toast(f"Could not open the dossier: {exc}") return config.add_recent_project(str(Path(path).resolve())) self.window.set_project(project) - def _on_new_project(self, *_a): - dialog = Gtk.FileDialog(title="Choose a folder for the new project") - - def done(dlg, res): - try: - folder = dlg.select_folder_finish(res) - except Exception: - return - path = folder.get_path() + def _on_new_dossier(self, *_a): + def create(name: str): try: - project = Project.create(path, name=Path(path).name) + project = Project.create_draft(name) except Exception as exc: # noqa: BLE001 - self.window.toast(f"Could not create project: {exc}") + self.window.toast(f"Could not create the dossier: {exc}") return - config.add_recent_project(str(Path(path).resolve())) + config.add_recent_project(str(project.root.resolve())) self.window.set_project(project) - self.window.toast(f"Created project “{project.name}”") - - dialog.select_folder(self.window, None, done) - - def _on_open_project(self, *_a): - dialog = Gtk.FileDialog(title="Open a gknowledge project folder") + self.window.toast(f"“{project.name}” is ready. Add sources; " + "you'll choose where to keep it when you save.") + + self.window.confirm_leave(lambda: self.window.ask_dossier_name(create)) + + def _on_open_dossier(self, *_a): + dialog = Gtk.FileDialog(title="Open a Dossier") + filters = Gio.ListStore.new(Gtk.FileFilter) + dossiers = Gtk.FileFilter(name="Dossiers") + dossiers.add_pattern("*.dossier") + dossiers.add_pattern("gknowledge.json") # created before 0.4 + filters.append(dossiers) + dialog.set_filters(filters) + dialog.set_default_filter(dossiers) + home = default_home() + if home.is_dir(): + dialog.set_initial_folder(Gio.File.new_for_path(str(home))) def done(dlg, res): try: - folder = dlg.select_folder_finish(res) + gfile = dlg.open_finish(res) except Exception: return - path = folder.get_path() + path = gfile.get_path() if not Project.is_project(path): - self.window.toast("That folder is not a gknowledge project.") + self.window.toast("That file does not open a dossier.") return self._open_path(path) - dialog.select_folder(self.window, None, done) + self.window.confirm_leave(lambda: dialog.open(self.window, None, done)) + + def _on_save_dossier(self, *_a): + if self.window: + self.window.save_draft() def _on_preferences(self, *_a): if self.window: @@ -154,9 +165,11 @@ def _on_about(self, *_a): about.present() def _on_quit(self, *_a): - if self.window and self.window.project: - self.window.project.close() - self.quit() + # Closing the window asks about an unsaved draft, then ends the app. + if self.window: + self.window.close() + else: + self.quit() def main(argv=None) -> int: diff --git a/gknowledge/config.py b/gknowledge/config.py index 640bb10..b12084f 100644 --- a/gknowledge/config.py +++ b/gknowledge/config.py @@ -48,3 +48,11 @@ def add_recent_project(path: str) -> None: recent.insert(0, path) state["recent"] = recent[:10] save_state(state) + + +def replace_recent_project(old: str, new: str) -> None: + """A draft was saved elsewhere: its recent entry follows it.""" + state = load_state() + state["recent"] = [p for p in state.get("recent", []) if p not in (old, new)] + save_state(state) + add_recent_project(new) diff --git a/gknowledge/core/extraction.py b/gknowledge/core/extraction.py index 09cd76a..dca6bf4 100644 --- a/gknowledge/core/extraction.py +++ b/gknowledge/core/extraction.py @@ -105,7 +105,7 @@ def analyze(self, text: str) -> dict: # -- step 2 -------------------------------------------------------------- def _mentions_path(self): - return self.project.root / "cache" / "mentions.json" + return self.project.cache_dir / "mentions.json" def _load_mentions(self) -> dict: try: diff --git a/gknowledge/core/project.py b/gknowledge/core/project.py index 080be18..4cb74c7 100644 --- a/gknowledge/core/project.py +++ b/gknowledge/core/project.py @@ -1,12 +1,12 @@ -"""Project layer: the on-disk workspace format. +"""Project layer: the on-disk dossier format (see docs/dossier/SPEC.md). -The layout is deterministic and human-readable so a user can inspect, back up, -or version-control a project without the app. Generated knowledge is Markdown -with YAML frontmatter (Obsidian-compatible); metadata and provenance live in a -SQLite index under ``index/``. +A dossier is a folder whose visible content a person can read at a glance: +a launcher file that opens it, the evidence, the compiled knowledge and their +own notes. Everything that can be rebuilt, or belongs to one machine, is kept +out of sight in ``.dossier/``. - / - gknowledge.json # manifest + / + .dossier # manifest; double-click opens the dossier sources/ # imported raw files (immutable copies) knowledge/ index.md # catalog of pages @@ -14,23 +14,36 @@ overview.md # global summary (regenerated on ingest) sources/ entities/ concepts/ topics/ answers/ synthesis/ notes/ # user-authored notes - reviews/ # (reserved) exported review snapshots - index/knowledge.db # SQLite store - cache/ # transient parse/analysis cache - settings/settings.json # provider + app settings + .dossier/ # derived and local state, never shared + index/knowledge.db # SQLite store + cache/ # transient parse/analysis cache + settings.json # provider + app settings + +Dossiers created before 0.4 use the legacy layout (``gknowledge.json``, +``index/``, ``cache/``, ``settings/`` at the root); they open unchanged. + +A new dossier starts as a *draft*: it has a name and is fully usable, but it +lives in the user's data dir until it is saved to a real folder +(:meth:`Project.materialize`). """ from __future__ import annotations import json +import os +import re +import shutil from dataclasses import asdict, dataclass, field from pathlib import Path from .models import PageKind, new_id, now from .store import Store -MANIFEST = "gknowledge.json" -FORMAT_VERSION = 1 +MANIFEST_SUFFIX = ".dossier" +LEGACY_MANIFEST = "gknowledge.json" +FORMAT_VERSION = "0.1" +STATE_DIR = ".dossier" +DEFAULT_HOME_NAME = "Dossiers" KNOWLEDGE_SUBDIRS = { PageKind.SOURCE: "sources", @@ -42,6 +55,77 @@ } +def find_manifest(root: str | Path) -> Path | None: + """The manifest of the dossier at ``root``: ``*.dossier``, else legacy.""" + root = Path(root) + if not root.is_dir(): + return None + launchers = sorted(p for p in root.glob(f"*{MANIFEST_SUFFIX}") if p.is_file()) + if launchers: + return launchers[0] + legacy = root / LEGACY_MANIFEST + return legacy if legacy.is_file() else None + + +def dossier_root(path: str | Path) -> Path: + """Accept either the dossier folder or its launcher file.""" + path = Path(path) + if path.is_file() and path.name.endswith((MANIFEST_SUFFIX, LEGACY_MANIFEST)): + return path.parent + return path + + +def safe_filename(name: str) -> str: + """A folder/file name for ``name``: keeps spaces and accents, drops separators.""" + cleaned = re.sub(r'[/\\\x00-\x1f:*?"<>|]+', " ", name) + cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".") + return cleaned[:120].strip() or "Untitled dossier" + + +def _xdg_documents_dir() -> Path: + """The user's Documents folder, localised ("Documenti", "Dokumente", …).""" + home = Path.home() + config = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") + try: + for line in (config / "user-dirs.dirs").read_text("utf-8").splitlines(): + m = re.match(r'\s*XDG_DOCUMENTS_DIR\s*=\s*"(.*)"\s*$', line) + if m: + value = m.group(1).replace("$HOME", str(home)) + if value and Path(value) != home: + return Path(value) + except OSError: + pass + return home / "Documents" + + +def default_home() -> Path: + """Where new dossiers are saved by default: ``~/Documents/Dossiers``. + + ``GKNOWLEDGE_DOSSIER_HOME`` overrides it (tests, sandboxes). + """ + override = os.environ.get("GKNOWLEDGE_DOSSIER_HOME") + if override: + return Path(override) + return _xdg_documents_dir() / DEFAULT_HOME_NAME + + +def drafts_dir() -> Path: + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return Path(base) / "gknowledge" / "drafts" + + +def unique_path(path: Path) -> Path: + """``path``, or ``path (2)``, ``path (3)``… if it already exists.""" + if not path.exists(): + return path + n = 2 + while True: + candidate = path.with_name(f"{path.name} ({n})") + if not candidate.exists(): + return candidate + n += 1 + + @dataclass class Settings: provider: str = "offline" # offline | chatgpt | openai | custom | ollama @@ -63,6 +147,9 @@ def from_dict(cls, d: dict) -> "Settings": class Project: def __init__(self, root: str | Path): self.root = Path(root) + manifest = find_manifest(self.root) + self.legacy = manifest is not None and manifest.name == LEGACY_MANIFEST + self.index_dir.mkdir(parents=True, exist_ok=True) self.store = Store(self.index_dir / "knowledge.db") self._settings = self._load_settings() @@ -72,18 +159,22 @@ def create(cls, root: str | Path, name: str = "") -> "Project": root = Path(root) root.mkdir(parents=True, exist_ok=True) name = name or root.name - for sub in ("sources", "knowledge", "notes", "reviews", "index", - "cache", "settings"): - (root / sub).mkdir(exist_ok=True) + for sub in ("sources", "knowledge", "notes", + f"{STATE_DIR}/index", f"{STATE_DIR}/cache"): + (root / sub).mkdir(parents=True, exist_ok=True) for sub in KNOWLEDGE_SUBDIRS.values(): (root / "knowledge" / sub).mkdir(exist_ok=True) manifest = { - "format_version": FORMAT_VERSION, + "dossier": FORMAT_VERSION, "id": new_id(), "name": name, - "created_at": now(), + "created": _rfc3339(now()), } - (root / MANIFEST).write_text(json.dumps(manifest, indent=2), "utf-8") + (root / f"{safe_filename(name)}{MANIFEST_SUFFIX}").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", "utf-8") + gitignore = root / ".gitignore" + if not gitignore.exists(): + gitignore.write_text(f"{STATE_DIR}/\n", "utf-8") proj = cls(root) # Seed the three navigation entry-point files. for fname, header in ( @@ -97,24 +188,54 @@ def create(cls, root: str | Path, name: str = "") -> "Project": proj.store.log("project.create", name) return proj + @classmethod + def create_draft(cls, name: str) -> "Project": + """A named, usable dossier that has no folder of its own yet.""" + return cls.create(drafts_dir() / new_id(), name=name) + @classmethod def open(cls, root: str | Path) -> "Project": - root = Path(root) - if not (root / MANIFEST).exists(): - raise FileNotFoundError(f"Not a gknowledge project: {root}") + root = dossier_root(root) + if find_manifest(root) is None: + raise FileNotFoundError(f"Not a dossier: {root}") return cls(root) @staticmethod def is_project(root: str | Path) -> bool: - return (Path(root) / MANIFEST).exists() + return find_manifest(dossier_root(root)) is not None def close(self) -> None: self.store.close() + # -- drafts -------------------------------------------------------------- + @property + def is_draft(self) -> bool: + return self.root.resolve().parent == drafts_dir().resolve() + + def suggested_folder(self, parent: str | Path | None = None) -> Path: + """Where :meth:`materialize` would put this dossier inside ``parent``.""" + parent = Path(parent) if parent else default_home() + return unique_path(parent / safe_filename(self.name)) + + def materialize(self, dest: str | Path) -> "Project": + """Move a draft to ``dest`` (which must not exist) and reopen it there. + + The draft is moved, not copied, so nothing is left behind; this + object is closed and the reopened dossier is returned. + """ + dest = Path(dest) + if dest.exists(): + raise FileExistsError(f"{dest} already exists") + dest.parent.mkdir(parents=True, exist_ok=True) + self.store.log("project.save", str(dest)) + self.close() + shutil.move(str(self.root), str(dest)) + return type(self).open(dest) + # -- paths --------------------------------------------------------------- @property def manifest_path(self) -> Path: - return self.root / MANIFEST + return find_manifest(self.root) or self.root / LEGACY_MANIFEST @property def sources_dir(self) -> Path: @@ -124,13 +245,17 @@ def sources_dir(self) -> Path: def knowledge_dir(self) -> Path: return self.root / "knowledge" + @property + def state_dir(self) -> Path: + return self.root if self.legacy else self.root / STATE_DIR + @property def index_dir(self) -> Path: - return self.root / "index" + return self.state_dir / "index" @property def cache_dir(self) -> Path: - return self.root / "cache" + return self.state_dir / "cache" def knowledge_subdir(self, kind: PageKind) -> Path: return self.knowledge_dir / KNOWLEDGE_SUBDIRS.get(kind, "concepts") @@ -150,7 +275,9 @@ def settings(self) -> Settings: return self._settings def _settings_path(self) -> Path: - return self.root / "settings" / "settings.json" + if self.legacy: + return self.root / "settings" / "settings.json" + return self.state_dir / "settings.json" def _load_settings(self) -> Settings: p = self._settings_path() @@ -168,3 +295,8 @@ def save_settings(self, settings: Settings) -> None: json.dumps(settings.as_dict(), indent=2), "utf-8" ) self.store.log("settings.save", settings.provider) + + +def _rfc3339(ts: float) -> str: + from datetime import datetime, timezone + return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/gknowledge/mcp.py b/gknowledge/mcp.py index a5ba946..91c1045 100644 --- a/gknowledge/mcp.py +++ b/gknowledge/mcp.py @@ -212,7 +212,7 @@ def main(argv: list[str] | None = None) -> int: if path and init and not Project.is_project(path): Project.create(path).close() if not path or not Project.is_project(path): - print("usage: gknowledge-mcp [--init] /path/to/gknowledge/project", + print("usage: gknowledge-mcp [--init] /path/to/dossier", file=sys.stderr) return 2 server = WikiServer(Project.open(path)) diff --git a/gknowledge/ui/window.py b/gknowledge/ui/window.py index 7af99e3..fd20371 100644 --- a/gknowledge/ui/window.py +++ b/gknowledge/ui/window.py @@ -10,7 +10,7 @@ gi.require_version("Adw", "1") from gi.repository import Adw, Gio, GLib, GObject, Gtk, Pango # noqa: E402 -from .. import APP_NAME, __version__ +from .. import APP_NAME, __version__, config from ..core.ingestion import IngestionManager from ..core.knowledge import KnowledgeWriter from ..core.lint import ChangeDetector @@ -101,6 +101,9 @@ def __init__(self, app: Adw.Application): self._add_win_action("import-files", self._on_import_files) self._add_win_action("import-folder", self._on_import_folder) self._add_win_action("ingest", self._on_ingest) + # A draft dossier is saved to a folder when the window closes. + self._closing = False + self.connect("close-request", self._on_close_request) def _add_win_action(self, name, cb): action = Gio.SimpleAction.new(name, None) @@ -111,8 +114,9 @@ def _add_win_action(self, name, cb): @staticmethod def _primary_menu() -> Gio.Menu: menu = Gio.Menu() - menu.append("New Project…", "app.new-project") - menu.append("Open Project…", "app.open-project") + menu.append("New Dossier…", "app.new-dossier") + menu.append("Open Dossier…", "app.open-dossier") + menu.append("Save Dossier…", "app.save-dossier") menu.append("Preferences", "app.preferences") menu.append("About gknowledge", "app.about") menu.append("Quit", "app.quit") @@ -122,18 +126,18 @@ def _build_welcome(self) -> Gtk.Widget: status = Adw.StatusPage( icon_name="accessories-dictionary-symbolic", title="gknowledge", - description="Turn your documents, notes and links into an evolving, " - "inspectable knowledge base.", + description="A dossier is a folder that knows what it contains: " + "add documents, ask questions, get answers with sources.", ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12, halign=Gtk.Align.CENTER) - new_btn = Gtk.Button(label="New Project…") + new_btn = Gtk.Button(label="New Dossier…") new_btn.add_css_class("suggested-action") new_btn.add_css_class("pill") - new_btn.connect("clicked", lambda *_: self.app.activate_action("new-project")) - open_btn = Gtk.Button(label="Open Project…") + new_btn.connect("clicked", lambda *_: self.app.activate_action("new-dossier")) + open_btn = Gtk.Button(label="Open Dossier…") open_btn.add_css_class("pill") - open_btn.connect("clicked", lambda *_: self.app.activate_action("open-project")) + open_btn.connect("clicked", lambda *_: self.app.activate_action("open-dossier")) box.append(new_btn) box.append(open_btn) status.set_child(box) @@ -204,6 +208,11 @@ def _build_workspace(self) -> Gtk.Widget: GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL) header.pack_start(toggle) + self.save_btn = Gtk.Button(label="Save…", visible=False, + tooltip_text="Choose where to keep this dossier") + self.save_btn.add_css_class("suggested-action") + self.save_btn.connect("clicked", lambda *_: self.save_draft()) + header.pack_end(self.save_btn) return self.split def _sync_sidebar(self, *_a): @@ -309,7 +318,7 @@ def set_project(self, project: Project): self.set_title(f"{project.name} — {APP_NAME}") self.sidebar_title.set_title(project.name) - self.sidebar_title.set_subtitle(self.provider.name) + self._update_draft_state() self.main_stack.set_visible_child_name("workspace") self.refresh_all() self.view_stack.set_visible_child_name("sources") @@ -320,7 +329,7 @@ def on_settings_saved(self, settings): if self.project: self.provider = build_provider(settings.as_dict()) self.ingestion.set_provider(self.provider) - self.sidebar_title.set_subtitle(self.provider.name) + self._update_draft_state() self.toast("Provider updated") def refresh_all(self): @@ -810,12 +819,145 @@ def _warn(self, text: str): self.banner.set_title(GLib.markup_escape_text(text[:240])) self.banner.set_revealed(True) + # -- draft dossiers -------------------------------------------------------- + # A new dossier only asks for a name. It lives as a draft until it is + # saved, on request or when the window closes, and only then gets a folder. + def _update_draft_state(self): + draft = bool(self.project and self.project.is_draft) + self.save_btn.set_visible(draft) + action = self.app.lookup_action("save-dossier") + if action: + action.set_enabled(draft) + if self.project: + provider = self.provider.name if self.provider else "" + self.sidebar_title.set_subtitle( + f"Draft, not saved yet · {provider}" if draft else provider) + + def ask_dossier_name(self, on_name): + dialog = Adw.AlertDialog.new("New Dossier", "Give it a name. You can start " + "working right away and choose where to keep it later.") + entry = Gtk.Entry(placeholder_text="e.g. Strait of Messina bridge: problem or opportunity") + dialog.set_extra_child(entry) + dialog.add_response("cancel", "Cancel") + dialog.add_response("create", "Create") + dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) + dialog.set_response_enabled("create", False) + dialog.set_default_response("create") + dialog.set_close_response("cancel") + done = {"v": False} + + def create(): + name = entry.get_text().strip() + if name and not done["v"]: + done["v"] = True + on_name(name) + + entry.connect("changed", lambda e: dialog.set_response_enabled( + "create", bool(e.get_text().strip()))) + entry.connect("activate", lambda *_: (create(), dialog.close())) + dialog.connect("response", lambda _d, r: create() if r == "create" else None) + dialog.present(self) + entry.grab_focus() + + def save_draft(self, then=None): + """Give the draft a folder: the default home, or one the user picks.""" + if not (self.project and self.project.is_draft): + if then: + then() + return + if self.ingestion and self.ingestion.is_running(): + self.toast("Wait for the sources to finish processing, then save.") + return + from ..core.project import default_home + dest = self.project.suggested_folder() + shown = str(dest).replace(str(Path.home()), "~", 1) + dialog = Adw.AlertDialog.new(f"Save “{self.project.name}”", + f"The dossier will be kept in\n{shown}") + dialog.add_response("cancel", "Cancel") + dialog.add_response("other", "Choose Another Folder…") + dialog.add_response("save", "Save") + dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def picked(dlg, res): + try: + folder = dlg.select_folder_finish(res) + except Exception: # noqa: BLE001 (cancelled) + return + self._materialize(self.project.suggested_folder(folder.get_path()), then) + + def on_response(_d, response): + if response == "save": + self._materialize(dest, then) + elif response == "other": + home = default_home() + chooser = Gtk.FileDialog(title="Choose where to keep the dossier") + if home.is_dir(): + chooser.set_initial_folder(Gio.File.new_for_path(str(home))) + chooser.select_folder(self, None, picked) + + dialog.connect("response", on_response) + dialog.present(self) + + def _materialize(self, dest: Path, then=None): + draft = self.project + try: + saved = draft.materialize(dest) + except Exception as exc: # noqa: BLE001 + self.toast(f"Could not save the dossier: {exc}") + return + self.project = None # the draft is closed and gone + config.replace_recent_project(str(draft.root.resolve()), str(saved.root.resolve())) + self.set_project(saved) + self.toast(f"Saved to {str(saved.root).replace(str(Path.home()), '~', 1)}") + if then: + then() + + def confirm_leave(self, then): + """Run ``then`` now, or after asking what to do with an unsaved draft.""" + if not (self.project and self.project.is_draft): + then() + return + dialog = Adw.AlertDialog.new( + f"Save “{self.project.name}”?", + "This dossier is still a draft. Save it to a folder to keep it " + "with your files, or keep it as a draft and come back to it later.") + dialog.add_response("cancel", "Cancel") + dialog.add_response("draft", "Keep as Draft") + dialog.add_response("save", "Save…") + dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def on_response(_d, response): + if response == "draft": + then() + elif response == "save": + self.save_draft(then) + + dialog.connect("response", on_response) + dialog.present(self) + + def _on_close_request(self, *_a): + if self._closing or not (self.project and self.project.is_draft): + if self.project: + self.project.close() + return False + + def proceed(): + self._closing = True + self.close() + + self.confirm_leave(proceed) + return True + def toast(self, text: str): self.toasts.add_toast(Adw.Toast(title=text, timeout=3)) def open_preferences(self): if not self.project: - self.toast("Open a project first") + self.toast("Open a dossier first") return dlg = PreferencesDialog(self, self.project, self.on_settings_saved) dlg.present() diff --git a/tests/test_core.py b/tests/test_core.py index 90c2eaa..2d987f0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -61,7 +61,10 @@ def _write_minimal_epub(path: Path, chapter: str) -> None: def test_project_layout(tmp_path): proj = make_project(tmp_path) assert Project.is_project(proj.root) - assert (proj.root / "gknowledge.json").exists() + assert (proj.root / "Test KB.dossier").exists() + assert (proj.root / ".dossier" / "index" / "knowledge.db").exists() + assert sorted(p.name for p in proj.root.iterdir() if not p.name.startswith(".")) == [ + "Test KB.dossier", "knowledge", "notes", "sources"] assert (proj.knowledge_dir / "index.md").exists() assert (proj.knowledge_dir / "overview.md").exists() assert proj.name == "Test KB" diff --git a/tests/test_dossier.py b/tests/test_dossier.py new file mode 100644 index 0000000..a90e11e --- /dev/null +++ b/tests/test_dossier.py @@ -0,0 +1,123 @@ +"""Dossier lifecycle: drafts, saving to a folder, launcher files, legacy layout.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from gknowledge.core import project as dossier +from gknowledge.core.ingestion import IngestionManager +from gknowledge.core.project import Project, Settings +from gknowledge.providers import build_provider + + +@pytest.fixture(autouse=True) +def isolated_dirs(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("GKNOWLEDGE_DOSSIER_HOME", raising=False) + + +def test_draft_has_a_name_and_works_before_it_has_a_folder(tmp_path): + draft = Project.create_draft("Ponte sullo stretto, problema o occasione") + assert draft.is_draft + assert draft.name == "Ponte sullo stretto, problema o occasione" + assert draft.root.parent == dossier.drafts_dir() + + note = tmp_path / "relazione.md" + note.write_text("# Ponte\n\nIl ponte collegherebbe Messina e Reggio Calabria.\n", "utf-8") + draft.save_settings(Settings(provider="offline")) + mgr = IngestionManager(draft, build_provider(draft.settings.as_dict())) + assert mgr.import_file(note) is not None + assert mgr.process_pending() == 1 + assert draft.store.list_pages() + draft.close() + + +def test_saving_moves_the_draft_into_the_default_home(tmp_path): + draft = Project.create_draft("Ponte sullo stretto, problema o occasione") + draft_root = draft.root + dest = draft.suggested_folder() + assert dest == (tmp_path / "home" / "Documents" / "Dossiers" + / "Ponte sullo stretto, problema o occasione") + + saved = draft.materialize(dest) + assert not draft_root.exists() + assert not saved.is_draft + assert saved.root == dest + assert saved.name == "Ponte sullo stretto, problema o occasione" + visible = sorted(p.name for p in dest.iterdir() if not p.name.startswith(".")) + assert visible == ["Ponte sullo stretto, problema o occasione.dossier", + "knowledge", "notes", "sources"] + assert (dest / ".gitignore").read_text("utf-8") == ".dossier/\n" + saved.close() + + +def test_suggested_folder_never_overwrites(tmp_path): + home = tmp_path / "elsewhere" + (home / "Tesi").mkdir(parents=True) + draft = Project.create_draft("Tesi") + assert draft.suggested_folder(home) == home / "Tesi (2)" + with pytest.raises(FileExistsError): + draft.materialize(home / "Tesi") + assert draft.root.exists() # nothing lost on refusal + draft.close() + + +def test_default_home_follows_localised_documents_dir(tmp_path): + config = tmp_path / "config" + config.mkdir() + (config / "user-dirs.dirs").write_text( + 'XDG_DESKTOP_DIR="$HOME/Scrivania"\nXDG_DOCUMENTS_DIR="$HOME/Documenti"\n', "utf-8") + assert dossier.default_home() == tmp_path / "home" / "Documenti" / "Dossiers" + + +def test_default_home_override(tmp_path, monkeypatch): + monkeypatch.setenv("GKNOWLEDGE_DOSSIER_HOME", str(tmp_path / "custom")) + assert dossier.default_home() == tmp_path / "custom" + + +def test_launcher_file_opens_the_dossier(tmp_path): + proj = Project.create(tmp_path / "Casa", name="Casa") + launcher = tmp_path / "Casa" / "Casa.dossier" + manifest = json.loads(launcher.read_text("utf-8")) + assert manifest["dossier"] == "0.1" and manifest["name"] == "Casa" + proj.close() + assert Project.is_project(launcher) + reopened = Project.open(launcher) + assert reopened.root == tmp_path / "Casa" + reopened.close() + + +def test_renamed_launcher_still_opens(tmp_path): + Project.create(tmp_path / "Casa", name="Casa").close() + os.rename(tmp_path / "Casa" / "Casa.dossier", tmp_path / "Casa" / "Casa al mare.dossier") + reopened = Project.open(tmp_path / "Casa") + assert reopened.name == "Casa" + reopened.close() + + +def test_legacy_project_opens_unchanged(tmp_path): + root = tmp_path / "old" + for sub in ("sources", "knowledge", "index", "cache", "settings"): + (root / sub).mkdir(parents=True) + (root / "gknowledge.json").write_text(json.dumps( + {"format_version": 1, "id": "abc", "name": "Old KB", "created_at": 1.0}), "utf-8") + (root / "settings" / "settings.json").write_text( + json.dumps({"provider": "ollama", "model": "llama3.2"}), "utf-8") + + proj = Project.open(root) + assert proj.legacy and proj.name == "Old KB" + assert proj.settings.provider == "ollama" + assert proj.index_dir == root / "index" + assert not (root / ".dossier").exists() + proj.close() + assert sorted(p.name for p in root.iterdir()) == [ + "cache", "gknowledge.json", "index", "knowledge", "settings", "sources"] From c969448d536cf4ac7144c8bce00ef14c64af68f7 Mon Sep 17 00:00:00 2001 From: francesco Date: Tue, 27 Oct 2026 19:57:30 +0100 Subject: [PATCH 2/3] fix(ui): a universal example in the New Dossier placeholder Co-Authored-By: Claude Opus 5.5 --- gknowledge/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gknowledge/ui/window.py b/gknowledge/ui/window.py index fd20371..ba58913 100644 --- a/gknowledge/ui/window.py +++ b/gknowledge/ui/window.py @@ -836,7 +836,7 @@ def _update_draft_state(self): def ask_dossier_name(self, on_name): dialog = Adw.AlertDialog.new("New Dossier", "Give it a name. You can start " "working right away and choose where to keep it later.") - entry = Gtk.Entry(placeholder_text="e.g. Strait of Messina bridge: problem or opportunity") + entry = Gtk.Entry(placeholder_text="e.g. Buying our first home") dialog.set_extra_child(entry) dialog.add_response("cancel", "Cancel") dialog.add_response("create", "Create") From 7aa8b15959c1d5652f8522570065383758378ca4 Mon Sep 17 00:00:00 2001 From: francesco Date: Tue, 27 Oct 2026 19:59:06 +0100 Subject: [PATCH 3/3] fix(ui): New Dossier asks for the name and nothing else Co-Authored-By: Claude Opus 5.5 --- gknowledge/app.py | 2 -- gknowledge/ui/window.py | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/gknowledge/app.py b/gknowledge/app.py index 4e343d6..23ee26b 100644 --- a/gknowledge/app.py +++ b/gknowledge/app.py @@ -109,8 +109,6 @@ def create(name: str): return config.add_recent_project(str(project.root.resolve())) self.window.set_project(project) - self.window.toast(f"“{project.name}” is ready. Add sources; " - "you'll choose where to keep it when you save.") self.window.confirm_leave(lambda: self.window.ask_dossier_name(create)) diff --git a/gknowledge/ui/window.py b/gknowledge/ui/window.py index ba58913..160e244 100644 --- a/gknowledge/ui/window.py +++ b/gknowledge/ui/window.py @@ -834,8 +834,7 @@ def _update_draft_state(self): f"Draft, not saved yet · {provider}" if draft else provider) def ask_dossier_name(self, on_name): - dialog = Adw.AlertDialog.new("New Dossier", "Give it a name. You can start " - "working right away and choose where to keep it later.") + dialog = Adw.AlertDialog.new("New Dossier", None) entry = Gtk.Entry(placeholder_text="e.g. Buying our first home") dialog.set_extra_child(entry) dialog.add_response("cancel", "Cancel")