From fc2c0b24e593fbebb72f493baa85c56afff86a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:27:07 +0000 Subject: [PATCH 1/4] Constructs together: count a purchase once, read sub-folders, files on the calendar, @name anywhere, resolved issues stop coming back - Ledger: a record's price is left out when an event with an amount links to it (text or front matter); that event is the purchase. Inventory still values the thing by its price. - Collections include their sub-folders (recordsIn/inCollection), so inventory, records, contacts, organizations and places agree, in the CLI and the browser; `records` counts sub-folders with each collection. - Sidecars feed upcoming, reminders, calendar and .ics, count as backlinks in related, and put their file at a place (places[].files). - `@name` expands anywhere a query is taken (every view, MCP, browser search boxes), beside other terms; an unknown name is NOT_FOUND. - A resolved issue's open to-dos, reminders and rrule repeats leave todos, upcoming, reminders, calendar and .ics (todos --all still lists them); the browser timeline and entry page show its status, not `issue: open`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XCVQuXXVJBEB4WExAcfQfD --- CHANGELOG.md | 12 + README.md | 12 +- SPEC.md | 10 +- docs/CLI.md | 44 +++- src/core/fields.ts | 39 ++- src/core/issues.ts | 25 ++ src/core/ledger.ts | 34 ++- src/core/places.ts | 16 +- src/core/search.ts | 32 ++- src/node/cli-files.ts | 3 +- src/node/cli-records.ts | 5 +- src/node/cli-views.ts | 37 ++- src/node/cli.ts | 36 ++- src/node/server.ts | 8 +- src/node/user-config.ts | 21 ++ src/web/components/App.tsx | 293 ++++++++++++----------- src/web/components/ContactsPage.tsx | 5 +- src/web/components/EntryDetail.tsx | 6 +- src/web/components/InventoryPage.tsx | 7 +- src/web/components/IssuesPage.tsx | 5 +- src/web/components/LedgerPage.tsx | 12 +- src/web/components/NotesPage.tsx | 5 +- src/web/components/OrganizationsPage.tsx | 5 +- src/web/components/PlacesPage.tsx | 21 +- src/web/components/RecordsPage.tsx | 7 +- src/web/components/SavedSearches.tsx | 48 +++- src/web/components/SeriesPage.tsx | 7 +- src/web/components/Timeline.tsx | 15 +- src/web/components/UpcomingPage.tsx | 9 +- src/web/store.ts | 7 + test/combinations.test.ts | 107 ++++++++- test/entities.test.ts | 16 ++ test/fields.test.ts | 50 +++- test/issues.test.ts | 24 +- test/ledger.test.ts | 15 ++ 35 files changed, 732 insertions(+), 266 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df95cba..0baa589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to GitRoll are documented here. GitRoll follows [semantic versioning](https://semver.org). The Roll file format has its own version, documented in [SPEC.md](SPEC.md). +## Unreleased + +### Changed +- **A purchase is counted once in the ledger.** An event with an `amount` that links to a thing, in its text or a front matter field, is the purchase, so the thing's own `price` is no longer added on top of it. A thing with a price and no such event is counted by its price as before, and `gitroll inventory` still values it by its price. SPEC.md says so under **Inventory vocabulary**. +- **A collection includes the folders under it.** `notes/inventory/tools/drill.md` is in `gitroll inventory`, `records inventory` and the browser app's Inventory, a place in `notes/places/home/` stays in the Places tree, and a person in `notes/people/family/` is a contact, the same way in every view, `--collection` and the browser app; `records books/sci-fi` still reads only that folder, and `gitroll records` counts each collection's sub-folders with it. SPEC.md says so under **Records and collections**. +- **A saved search works wherever a query does.** `@name` runs one in `issues`, `ledger`, `records`, `inventory`, `series`, `contacts`, `organizations`, `places`, `notes`, `todos`, `files` and every browser search box, as well as `find`, and over MCP, and combines with anything else typed beside it: `gitroll find "@unpaid amount>10"`. A name that isn't saved says so instead of finding nothing. +- **A resolved issue stops coming back.** Its open to-dos, its reminders and its repeats are left out of `todos`, `upcoming`, `reminders`, `calendar`, the `.ics` file and the browser app's Upcoming page; they stay in its file, `todos --all` still lists them, and reopening it brings them back. SPEC.md says so under **Issues**. + +### Fixed +- **A file's dates are on the calendar.** A sidecar's `expires` (or `warranty`, `due`, `renewal`, `start` and `remind`), as in SPEC.md's own passport example, never reached `upcoming`, `reminders`, `calendar` or the `.ics` file. It does now, and a sidecar's links count too: in `gitroll related` and an entry's page in the browser app, and in `gitroll places`, which lists a file whose sidecar links to a place under `files`. +- **The browser app says whether an issue is open.** The timeline and an entry's page showed the raw `issue: open` field on an issue long since resolved; they show **Open issue** or **Resolved issue** instead, as `gitroll issues` reads it. + ## 0.7.0 (2026-10-07) ### Added diff --git a/README.md b/README.md index 926f8ee..99c26b7 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,11 @@ gitroll issues # open issues, how long each has been open, gitroll close clunk --note "New sway bar link" # logs an event whose resolves: links to the issue ``` -An issue is resolved by `resolved: ` on it, or by a later event that `resolves:` it. The second way is read back like a backlink, so the issue's own file is left as it was and what fixed it is told in the event that fixed it. +An issue is resolved by `resolved: ` on it, or by a later event that `resolves:` it. The second way is read back like a backlink, so the issue's own file is left as it was and what fixed it is told in the event that fixed it. Once it is resolved, its open to-dos, reminders and repeats are off the calendar, though still in its file. ### Records, and the views over them -A folder of notes is a **collection** and each note in it a **record**, its fields in front matter. Nothing has to be declared first: the fields are whatever you wrote. +A folder of notes is a **collection** and each note in it a **record**, its fields in front matter, the folders under it included. Nothing has to be declared first: the fields are whatever you wrote. ```bash gitroll add books "Dune" --field rating=5 --field status=read @@ -167,7 +167,7 @@ The browser header and `gitroll status` show which repository and **branch** the | `gitroll restore ` | Put an earlier version back, as a new commit | | `gitroll conflicts` / `gitroll resolve --mine` | Settle an event that was changed in two places | | `gitroll related ` | What it links to, and what links back | -| `gitroll find "tag:incident" --save incidents` | Keep a search; run it later with `gitroll find @incidents` | +| `gitroll find "tag:incident" --save incidents` | Keep a search; run it later with `gitroll find @incidents`, or `@incidents` in any query (`gitroll ledger "@incidents after:2026-01-01"`) | | `gitroll find "postgres" --all` | Search every Roll you have | | `gitroll import github` / `gitroll import ci` | Log merged pull requests and releases, and builds that failed | @@ -319,7 +319,7 @@ gitroll sync | `gitroll upcoming [--days 30]` | What's coming up: appointments (`start`, `rrule`), to-dos with a `๐Ÿ“…` date, warranties and renewals | | `gitroll calendar --ics > roll.ics` | The Roll's calendar as an iCalendar file any calendar app can import, reminders included as alarms | | `gitroll remind "Call the dentist" --at "2026-11-01 09:00"` | A to-do with a reminder (`โฐ 2026-11-01 09:00`); `gitroll reminders [--due]` lists them, due ones first | -| `gitroll ledger [--by month] [--hledger]` | Totals of `amount` and `price` per currency, or an hledger journal of them | +| `gitroll ledger [--by month] [--hledger]` | Totals of `amount` and `price` per currency (a price only when no event with an amount links to the thing), or an hledger journal of them | | `gitroll series [query] [--by month]` | One number field over time (odometer, weight, a meter): each reading, a sparkline, change and rate per day and month | | `gitroll inventory [--by location]` | Your things (`notes/inventory/`): value, where they are, warranties ending, what to restock | | `gitroll contacts [query] [--vcf]` / `gitroll import vcf ` | People (`notes/people/`, vCard's field names) and when you last contacted each; an address book file out, and one in | @@ -361,7 +361,7 @@ itself is never touched: `gitroll set files/passport.pdf title=Passport expires=2030-05-01`, or write the front matter by hand. Sidecars use Dublin Core names (`title`, `creator`, `date`, `subject`, `description`) plus whatever else you like, and they are searched like any record: `gitroll find 'is:file -expires<2031'`. A photo's sidecar gets its date from the camera's EXIF data. +expires<2031'`. Its `expires` is on the calendar, as a record's would be, and a sidecar that links to a place puts the file there. A photo's sidecar gets its date from the camera's EXIF data. GitHub refuses files over 100 MB, so a file larger than 45 MB is kept as numbered parts โ€” `walkthrough.mp4.001`, `.002`, โ€ฆ โ€” with its size and sha256 in @@ -436,7 +436,7 @@ filters: Each one is a toggle over whatever is already in the box, in the order you list them. `filters: []` means no buttons at all. Leave the key out and you get the three defaults โ€” **With a photo** isn't among them, because a button that matches almost nothing in most Rolls is a button in the way; the line above puts it back. -Searches of your own go beside them. **Save search** keeps what is in the box under a name, the same as `gitroll find "tag:incident has:date" --save open-incidents`; one click runs it again, and **Edit saved** renames or deletes it. Saved searches are kept in your settings folder rather than in a Roll, so they are yours on this computer, offered in every Roll you open here, never committed or shared, and `gitroll find @open-incidents` runs the same one in a terminal. `gitroll searches` lists them. +Searches of your own go beside them. **Save search** keeps what is in the box under a name, the same as `gitroll find "tag:incident has:date" --save open-incidents`; one click runs it again, and **Edit saved** renames or deletes it. Saved searches are kept in your settings folder rather than in a Roll, so they are yours on this computer, offered in every Roll you open here, never committed or shared, and `gitroll find @open-incidents` runs the same one in a terminal. `@open-incidents` works in any search box and any command that takes a query, beside other filters. `gitroll searches` lists them. ### What backing up covers diff --git a/SPEC.md b/SPEC.md index 50537af..943b9d1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -60,6 +60,7 @@ finished: 2026-08-30 - **Nothing declares a collection or its fields.** The folder is the collection; the keys its records happen to use are its columns. A record without a key has no value for it, which is not an error. - A collection's name is its folder's path under `notes/` (`books`, `books/sci-fi`). Its `README.md` (in any case) describes it and is not one of its records. +- **A collection holds the folders under it.** `books` is the records in `notes/books/` and in every folder below it, so `books/sci-fi/dune.md` is a record of `books` and of `books/sci-fi`. A reader that reads one collection โ€” a table of its records, the inventory, contacts, organizations, places โ€” reads it this way, so moving a record into a sub-folder keeps it in every view it was in. - A record is a note in every other way: same format, same identity (its path), searched by `find`, and edited like any other file. - A writer that creates a record names the file after its title, as for any note, and heads it `# Title`. @@ -105,6 +106,7 @@ Renewed at the post office. The old one is in the drawer. - A reader shows the sidecar's `title` (else its first heading, else the file's name) in place of the file name. - `name.md` beside a file called `name`, or beside parts of it, is that file's sidecar; so is any `name.ext.md`, even before `name.ext` itself has arrived. A Markdown file kept as a file (`files/minutes.md`) is just a file, and its sidecar would be `minutes.md.md`. - **A sidecar is not an event or a note.** It isn't on the timeline. It is a record a search can find, as `is:file`, with the same field queries as any other. +- It is read as a record wherever records are read for their dates and links: its dated fields are on the calendar (`expires` above is, as it would be on a note; see **Calendar fields** and **Reminders**), and its links count as a note's do, in what links back to a document and in what is at a place. - A writer that creates a sidecar for a JPEG with no `date` may take the date from the photo's own EXIF `DateTimeOriginal`, as an ISO 8601 timestamp (with the camera's UTC offset when it recorded one). #### Large files in parts @@ -304,7 +306,7 @@ start: 2026-10-01 A reader that expands `rrule` should understand at least this subset: `FREQ` (`DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`), `INTERVAL`, `COUNT`, `UNTIL` (`20261231` or `20261231T235959Z`), and `BYDAY` with weekday names (`MO,WE,FR`) for `WEEKLY`; `WKST=MO` is the default and may be written. `COUNT` and `UNTIL` are not given together. As RFC 5545 says, a monthly rule on the 31st skips months without a 31st, and a yearly rule on 29 February happens in leap years only; `start` should be an occurrence of its own rule. A rule outside the subset is reported, not guessed at, and its `start` is still on the calendar. -What's on a Roll's calendar is derived, never stored: every `start` and its repeats, every event dated ahead of today, every open to-do with a `๐Ÿ“…` date, any date in a field named `warranty`, `expires`, `due` or `renewal`, and every year's `bday` and `anniversary` (see **Contact vocabulary**). An iCalendar export writes each `rrule` as an RRULE rather than a list of dates, gives each item a UID made from its file's path and which item of the file it is, and writes each reminder as a VALARM (see [Reminders](#reminders)). +What's on a Roll's calendar is derived, never stored, from events, notes and files' sidecars: every `start` and its repeats, every event dated ahead of today, every open to-do with a `๐Ÿ“…` date, any date in a field named `warranty`, `expires`, `due` or `renewal`, and every year's `bday` and `anniversary` (see **Contact vocabulary**). A resolved issue's open to-dos, reminders and repeats are not on it (see **Issues**). An iCalendar export writes each `rrule` as an RRULE rather than a list of dates, gives each item a UID made from its file's path and which item of the file it is, and writes each reminder as a VALARM (see [Reminders](#reminders)). ## Reminders @@ -366,7 +368,7 @@ Things are records with [schema.org](https://schema.org/Product) names for their A place is a record too (`.gitroll/notes/places/garage.md`, see **Place vocabulary**), and its own `within:` link (or schema.org's `containedInPlace`) puts it inside another, so places nest: Shelf 2 within Garage within House. The value of a thing is `price` ร— `quantity` (a missing quantity counts as one), totalled per currency and never converted. Events that link to a thing are its history, read as backlinks. -A ledger view totals events' `amount` and records' `price` per currency in the same way; it is a source of transactions for an accounting tool (hledger, Ledger), not one itself. +A ledger view totals events' `amount` and records' `price` per currency in the same way; it is a source of transactions for an accounting tool (hledger, Ledger), not one itself. A record's `price` is left out of it when an event with an `amount` links to the record, in its text or a front matter field: that event is the transaction, and counting the price too would count the purchase twice. A thing with a price and no such event is counted by its price, and its value in the inventory is its price either way. ## Contact vocabulary @@ -425,7 +427,7 @@ Places are records with [schema.org](https://schema.org/Place) Place names for t | `telephone`, `url` | How to reach it | | `within` | A link to the place it is in: `"[House](house.md)"`. schema.org's `containedInPlace` is read when there is no `within` | -Every other key is yours. `within` nests places into a tree, and a chain that loops is cut where it would repeat. What is at a place is what links to it: a thing whose `location` links to it, and a person, an organization or any other note that links to it in its text or a front matter field. Events that link to it are what happened there, newest first; an event's `location` (see **Calendar fields**) may be such a link. +Every other key is yours. `within` nests places into a tree, and a chain that loops is cut where it would repeat. What is at a place is what links to it: a thing whose `location` links to it, a person, an organization or any other note that links to it in its text or a front matter field, and a file whose sidecar does. Events that link to it are what happened there, newest first; an event's `location` (see **Calendar fields**) may be such a link. Why these: `latitude` and `longitude` are schema.org Place's own properties, and two plain numbers are what field queries (`latitude>51`) and sorting already understand, with nothing to parse; a writer shows them as an [RFC 5870](https://www.rfc-editor.org/rfc/rfc5870) `geo:` URI, `geo:51.5014,-0.1419`, which phones and map apps open. A `geo` key is read too, when there is no `latitude` or `longitude`, as schema.org's GeoCoordinates mapping (`{latitude, longitude}`) or a `geo:` URI as text, so either can be pasted in. `within` is the name the inventory already used before places had a view of their own, and stays the one a writer uses. @@ -506,6 +508,8 @@ What a reader works out, never stored: A reader that lists issues shows open ones, newest activity first, unless asked for resolved ones too. `is:issue` in a search matches documents marked as issues, open or resolved. +**A resolved issue is dealt with.** A reader leaves its open to-dos, its reminders (`โฐ` and `remind`) and the repeats of its `rrule` off the calendar, upcoming, reminders and an iCalendar export, and off a list of to-dos unless asked for everything. They stay in its file as written, and come back if it is reopened. A reader that shows an issue shows whether it is open or resolved, worked out as above, rather than its `issue` field, which says only that it is one. + Why these: an issue is a property of something already in the log, so it is a field on it rather than a new kind of file; `open` reads as what it means and `true` is what a person types when a flag is all they want. `resolves` is the word GitHub already reads in a pull request (`Resolves #12`) for the same relationship, and making it a link means it still resolves on GitHub and in a text editor. ## Dates diff --git a/docs/CLI.md b/docs/CLI.md index d88a3db..fac15de 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -79,7 +79,13 @@ The browser app's **All Rolls** search runs the same search over the same Rolls, with the open Roll first even when it isn't on the list, and shows up to 50 results from each. Saved searches (`find --save`, `searches`) are the same ones in the terminal and the browser app: they live in the settings folder's -`config.json`, not in any Roll. +`config.json`, not in any Roll. `@name` runs one wherever a query is taken โ€” +`find`, `issues`, `ledger`, `records`, `inventory`, `series`, `contacts`, +`organizations`, `places`, `notes`, `todos`, `files`, the browser app's search +boxes and the same tools over MCP โ€” and combines with other terms: +`gitroll ledger '@unpaid after:2026-01-01'`. A saved search may use another. +Inside double quotes `@name` is text to find. A name that isn't saved is a +`NOT_FOUND` error, never an empty result. ## Fields, records and collections @@ -110,14 +116,20 @@ gitroll find 'expires<2026-11-01 has:policy' -C /path/to/roll --json come last. A folder under `.gitroll/notes/` is a **collection** and each `.md` in it is a -**record**. A collection's `README.md` describes it and isn't a record. +**record**. A collection's `README.md` describes it and isn't a record. A +collection holds the folders under it too: `records inventory`, `inventory` +and `--collection inventory` read `inventory/tools/drill.md` as well, while +`records inventory/tools` reads only that folder. The same goes for every view +that reads a collection (`contacts`, `organizations`, `places`) and for the +browser app's pages. ```bash gitroll records -C /path/to/roll --json gitroll records books 'rating>=4' --sort=-rating --fields rating,status --limit 20 -C /path/to/roll --json ``` -With no collection, `records` returns `{name, path, records, description}[]`. +With no collection, `records` returns `{name, path, records, description}[]`, +one per folder that holds a note, `records` counting the folders under it too. With one, it returns `{collection, description, columns, total, records}` where each record is `{path, title, fields}` and `fields` has one key per column (`null` when the record has none). Columns are every front matter key in use in @@ -233,10 +245,13 @@ gitroll places 'has:address' -C /path/to/roll --json `trail` (names, outermost first), `depth`, `children`, then what is there as `{path, title}[]`: `items` (records whose `location` links to it), `people` and `organizations` (records in those collections that link to it) and - `notes` (any other note that does), and `events` (`{path, title, date}`, + `notes` (any other note that does), `files` (files whose sidecar links to + it, by the file's path and title), and `events` (`{path, title, date}`, newest first). A query keeps the tree for the places it matches; a place whose parent didn't match starts at depth 0 and keeps its `trail`. -- Both take `--collection ` to read another collection. +- Both take `--collection ` to read another collection. A place, person + or organization in a folder under its collection (`places/home/garage.md`, + `people/family/sam.md`) is one of them. ## Pins and issues @@ -280,6 +295,11 @@ gitroll close 2026-09-20-clunk --note "New sway bar link" -C /path/to/roll --jso retry is safe. A document that isn't marked as an issue is refused with `USER_ERROR`. The name is looked up among issues first, so the words that named an issue still name it once `Resolved: โ€ฆ` shares them. +- Once an issue is resolved, it stops coming back: its open to-dos, its `โฐ` + and `remind` reminders and its `rrule` repeats are left out of `todos`, + `upcoming`, `reminders`, `calendar` and `calendar --ics`, and the browser + app's Upcoming page. They stay in its file as written; `todos --all` still + lists its to-dos, and reopening it (taking `resolved` out) brings them back. - `close` is not `resolve`: `gitroll resolve` settles a sync conflict. ## Calendar, ledger, inventory and series @@ -300,7 +320,8 @@ gitroll label notes/inventory/heat-pump --svg -C /path/to/roll > heat-pump.svg ``` - `upcoming` returns `{date, kind, title, path, โ€ฆ}[]` by date, from today to - `--days` ahead (default 30): `start` dates and their `rrule` repeats + `--days` ahead (default 30), read from events, notes and files' sidecars: + `start` dates and their `rrule` repeats (`kind: "occurrence"`), events dated ahead, open to-dos with an Obsidian Tasks `๐Ÿ“…` date (`kind: "todo"`, with `line`, `text` and `recurrence`; an open one whose date has passed is listed first with `overdue: true`), and `warranty`, @@ -343,7 +364,10 @@ gitroll label notes/inventory/heat-pump --svg -C /path/to/roll > heat-pump.svg one commit, and returns it as `next`. - `ledger [query]` totals events' `amount` and records' `price` (with `priceCurrency`) **per currency, never mixed or converted**: `{by, totals, - groups, entries}`. `--by month|year|project|tag|` groups them; + groups, entries}`. A record's `price` is left out when an event with an + `amount` links to the record, in its text or a front matter field: that + event is the purchase, counted once. A thing with a price and no such event + is counted as before, and `inventory` still values it by its price. `--by month|year|project|tag|` groups them; project and tag groups can overlap. `--hledger` prints an hledger/Ledger journal instead โ€” each entry posted to `expenses:` and balanced by `assets:unknown` (with `--json`, `{journal}`). GitRoll is a @@ -412,7 +436,11 @@ front matter fields and an optional description underneath. Dublin Core names are used where one fits โ€” `title`, `creator`, `date`, `subject` (read as tags), `description` โ€” and any other key works (`expires`). A sidecar isn't an event or a note: `find` returns it only alongside them, and `is:file` narrows a search to -sidecars. Its `title` is shown instead of the file name. +sidecars. Its `title` is shown instead of the file name. Its dated fields are on +the calendar as a record's are (`expires`, `warranty`, `due`, `renewal`, +`start`, `remind`), in `upcoming`, `reminders`, `calendar` and the `.ics`; and +its links count where links do: in `related`'s backlinks, and in `places`, +where a file whose sidecar links to a place is listed there. ```bash gitroll set files/passport.pdf expires=2031-05-01 -C /path/to/roll --json diff --git a/src/core/fields.ts b/src/core/fields.ts index 84a1340..653b0e1 100644 --- a/src/core/fields.ts +++ b/src/core/fields.ts @@ -246,6 +246,24 @@ export function collectionOf(path: string): string | null { return cut > 0 ? rest.slice(0, cut) : null; } +/** + * Whether a note is in a collection, named in any case: in its folder or in a + * folder under it, so `inventory` holds `inventory/tools/drill.md` too. + */ +export function inCollection(path: string, name: string): boolean { + const at = collectionOf(path)?.toLowerCase(); + const want = trimSlashes(name).toLowerCase(); + return !!at && !!want && (at === want || at.startsWith(`${want}/`)); +} + +const trimSlashes = (name: string): string => { + let start = 0; + let end = name.length; + while (start < end && name[start] === "/") start++; + while (end > start && name[end - 1] === "/") end--; + return name.slice(start, end); +}; + /** A collection's README.md describes it; it isn't one of its records. */ export const isCollectionReadme = (path: string): boolean => collectionOf(path) !== null && README.test(path.slice(path.lastIndexOf("/") + 1)); @@ -275,30 +293,27 @@ function describe(readme: Entry | undefined): string | null { return para.join(" ") || readme.title || null; } -/** Every collection the notes are in, by name, with how many records each holds. */ +/** + * Every collection the notes are in, by name, with how many records each + * holds: its own and those in the folders under it, as recordsIn reads it. + */ export function collections(notes: Entry[]): Collection[] { - const found = new Map(); + const found = new Map(); for (const n of notes) { const name = collectionOf(n.path); if (name === null) continue; - const c = found.get(name) ?? { count: 0 }; + const c = found.get(name) ?? {}; if (isCollectionReadme(n.path)) c.readme = n; - else c.count++; found.set(name, c); } return [...found] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([name, c]) => ({ name, path: `${NOTES_DIR}/${name}`, records: c.count, description: describe(c.readme) })); + .map(([name, c]) => ({ name, path: `${NOTES_DIR}/${name}`, records: recordsIn(notes, name).length, description: describe(c.readme) })); } -/** The records in one collection (named in any case), README excluded. */ +/** The records in one collection (named in any case) and the folders under it, READMEs excluded. */ export function recordsIn(notes: T[], name: string): T[] { - let start = 0; - let end = name.length; - while (start < end && name[start] === "/") start++; - while (end > start && name[end - 1] === "/") end--; - const want = name.slice(start, end).toLowerCase(); - return notes.filter((n) => collectionOf(n.path)?.toLowerCase() === want && !isCollectionReadme(n.path)); + return notes.filter((n) => inCollection(n.path, name) && !isCollectionReadme(n.path)); } /** A GitRoll bookkeeping mapping (`source: {adapter, id}`) isn't something a person reads as a column. */ diff --git a/src/core/issues.ts b/src/core/issues.ts index 8750a11..5e95437 100644 --- a/src/core/issues.ts +++ b/src/core/issues.ts @@ -21,6 +21,7 @@ import { dateField, metaValue } from "./calendar.ts"; import type { Interaction } from "./contacts.ts"; import { eventsLinking, incomingLinks } from "./organizations.ts"; import { fieldLink } from "./relations.ts"; +import type { Todo } from "./todos.ts"; export const ISSUE_FIELD = "issue"; export const RESOLVED_FIELD = "resolved"; @@ -157,3 +158,27 @@ export function issues(candidates: Entry[], docs: Entry[], today: string, opts: }); return { open, resolved: read.length - open, issues: listed }; } + +/** The paths of the issues among `docs` that are resolved, on themselves or by an event. */ +export function resolvedIssues(docs: Entry[]): Set { + const resolvers = resolversOf(docs); + return new Set(docs.filter((d) => isIssue(d) && (resolvedField(d) || resolvers.has(d.path))).map((d) => d.path)); +} + +/** The keys that make a document come round again, or say when to be told about it. */ +const CALENDAR_KEYS = new Set(["rrule", "remind"]); + +/** + * What the calendar, upcoming and reminders read once an issue is dealt with: + * a resolved issue's open to-dos, its reminders and its repeats are left out. + * They stay in its file, as written; only the views stop coming back to them. + * `docs` is every document, so what resolves an issue is found. + */ +export function withoutResolved(docs: T[], todos: U[]): { docs: T[]; todos: U[] } { + const resolved = resolvedIssues(docs); + if (!resolved.size) return { docs, todos }; + return { + docs: docs.map((d) => (resolved.has(d.path) ? { ...d, meta: Object.fromEntries(Object.entries(d.meta).filter(([k]) => !CALENDAR_KEYS.has(k.toLowerCase()))) } : d)), + todos: todos.filter((t) => !resolved.has(t.path)), + }; +} diff --git a/src/core/ledger.ts b/src/core/ledger.ts index 6a648b6..d20da45 100644 --- a/src/core/ledger.ts +++ b/src/core/ledger.ts @@ -3,7 +3,9 @@ // This is a view, never accounting. An event's `amount` (with `currency`) and a // record's `price` (with schema.org's `priceCurrency`) are added up per currency // โ€” amounts in different currencies are never added together or converted โ€” and -// can be grouped by month, year, project, tag or any field. `toHledger` writes +// can be grouped by month, year, project, tag or any field. A price counts only +// when no event with an amount links to its record: that event is the purchase, +// and the price is then only what the thing is worth. `toHledger` writes // the same entries as a plain-text accounting journal (hledger and Ledger read // it), so GitRoll stays a source of transactions and the accounting happens in // a tool built for it. @@ -11,6 +13,7 @@ import type { Amount, Entry } from "./entry.ts"; import { fieldValue } from "./fields.ts"; import { metaValue } from "./calendar.ts"; +import { documentLinks } from "./relations.ts"; import { formatAmount, parseAmount } from "./util.ts"; export interface LedgerEntry { @@ -67,11 +70,24 @@ export function priceOf(meta: Record): Amount | null { return null; } -/** What each document cost: its `amount`, or else its `price`. A document with neither isn't in the ledger. */ -export function ledgerEntries(docs: Entry[]): LedgerEntry[] { +/** The documents an event with an `amount` links to, in its text or its front matter: what it paid for. */ +export function paidFor(docs: Entry[]): Set { + const out = new Set(); + for (const e of docs) if (e.amount && e.path.toLowerCase().startsWith(".gitroll/events/")) for (const to of documentLinks(e)) out.add(to); + return out; +} + +/** + * What each document cost: its `amount`, or else its `price`. A document with + * neither isn't in the ledger, and neither is the price of a record an event + * with an amount links to: that event is the transaction, counted once. `all` + * is every document, so the event is found when `docs` is a search. + */ +export function ledgerEntries(docs: Entry[], all: Entry[] = docs): LedgerEntry[] { const out: LedgerEntry[] = []; + const paid = paidFor(all); for (const e of docs) { - const price = e.amount ? null : priceOf(e.meta); + const price = e.amount || paid.has(e.path) ? null : priceOf(e.meta); const amount = e.amount ?? price; if (!amount) continue; const date = e.amount ? day(e.date) : (day(metaValue(e.meta, "purchaseDate")) ?? day(e.date)); @@ -121,9 +137,13 @@ export interface Ledger { entries: LedgerEntry[]; } -/** Totals per currency, overall and (with `by`) per group. Groups by project or tag can overlap, since an entry can have several. */ -export function ledger(docs: Entry[], by?: string): Ledger { - const entries = ledgerEntries(docs); +/** + * Totals per currency, overall and (with `by`) per group. Groups by project or + * tag can overlap, since an entry can have several. `all` is every document, + * when `docs` is a search of them. + */ +export function ledger(docs: Entry[], by?: string, all: Entry[] = docs): Ledger { + const entries = ledgerEntries(docs, all); const docOf = new Map(docs.map((d) => [d.path, d])); const groups = new Map(); if (by) { diff --git a/src/core/places.ts b/src/core/places.ts index c4c4bb8..83414d9 100644 --- a/src/core/places.ts +++ b/src/core/places.ts @@ -22,7 +22,8 @@ import type { Entry } from "./entry.ts"; import { metaValue } from "./calendar.ts"; import { PEOPLE_COLLECTION, values } from "./contacts.ts"; import type { Interaction } from "./contacts.ts"; -import { collectionOf } from "./fields.ts"; +import { inCollection } from "./fields.ts"; +import { inFiles } from "./files.ts"; import { parentPlaceValue } from "./inventory.ts"; import { ORGANIZATIONS_COLLECTION, addressText, eventsLinking, incomingLinks, refOf } from "./organizations.ts"; @@ -134,6 +135,8 @@ export interface PlaceRecord { organizations: Here[]; /** Other notes that link here. */ notes: Here[]; + /** Files whose sidecar links here, by the file's own path. */ + files: Here[]; /** Events that link here, in their text or a field such as `location`, newest first. */ events: Interaction[]; } @@ -176,13 +179,15 @@ export function places(records: Entry[], docs: Entry[], collection = PLACES_COLL const people: Here[] = []; const orgs: Here[] = []; const notes: Here[] = []; + const files: Here[] = []; for (const d of into.get(r.path) ?? []) { if (d.path.toLowerCase().startsWith(".gitroll/events/")) continue; const here = { path: d.path, title: d.title }; - const collection = collectionOf(d.path)?.toLowerCase(); - if (parentOf(d) === r.path && (listed.has(d.path) || collection === PLACES_COLLECTION)) continue; // a place within it - if (collection === PEOPLE_COLLECTION) people.push({ ...here, title: values(metaValue(d.meta, "fn"))[0] ?? d.title }); - else if (collection === ORGANIZATIONS_COLLECTION) orgs.push(here); + // A file is there when its sidecar links here, in a field or in its text. + if (inFiles(d.path)) files.push({ path: d.attachments[0]?.path ?? d.path, title: d.title }); + else if (parentOf(d) === r.path && (listed.has(d.path) || inCollection(d.path, collection))) continue; // a place within it + else if (inCollection(d.path, PEOPLE_COLLECTION)) people.push({ ...here, title: values(metaValue(d.meta, "fn"))[0] ?? d.title }); + else if (inCollection(d.path, ORGANIZATIONS_COLLECTION)) orgs.push(here); else if (refOf(d, metaValue(d.meta, "location"))?.path === r.path) items.push(here); else notes.push(here); } @@ -202,6 +207,7 @@ export function places(records: Entry[], docs: Entry[], collection = PLACES_COLL people: people.sort(byTitle), organizations: orgs.sort(byTitle), notes: notes.sort(byTitle), + files: files.sort(byTitle), events: eventsLinking(r.path, into), }; }; diff --git a/src/core/search.ts b/src/core/search.ts index 21ce430..a82edef 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -14,7 +14,7 @@ import type { Entry } from "./entry.ts"; import { normalizeTag } from "./entry.ts"; import { todosIn } from "./todos.ts"; -import { slugify } from "./util.ts"; +import { NotFoundError, UserError, slugify } from "./util.ts"; import { compareValue, fieldValue, hasField, matchesValue } from "./fields.ts"; import { isSealedValue, withoutSealed } from "./sealed.ts"; import { isPinned } from "./pins.ts"; @@ -163,6 +163,36 @@ export function serialize(tokens: Token[]): string { .join(" "); } +/** `@name` standing alone, a saved search: anchored at a space or an end on both sides, so it runs in linear time. */ +const SAVED_SEARCH = /(^|\s)@([\w-]+)(?=\s|$)/g; + +/** + * A query with each `@name` in it replaced by the search saved under that + * name, so `@unpaid amount>10` is that search and one more filter. `lookup` + * gives the saved query, or undefined when there is none, which is an error: + * a saved search that isn't there would otherwise quietly match nothing. + * Inside double quotes `@name` is text to find. A saved search may use + * another, never itself. + */ +export function expandSavedSearches(query: string, lookup: (name: string) => string | undefined, seen: string[] = []): string { + if (!query.includes("@")) return query; + const parts = query.split('"'); + // An odd part is between a quote and the one that closes it; a quote that never closes is just a character. + const quoted = (i: number) => i % 2 === 1 && !(parts.length % 2 === 0 && i === parts.length - 1); + return parts + .map((part, i) => + quoted(i) + ? part + : part.replace(SAVED_SEARCH, (_, before: string, name: string) => { + if (seen.includes(name.toLowerCase())) throw new UserError(`The saved search "@${name}" uses itself.`); + const saved = lookup(name); + if (saved === undefined) throw new NotFoundError(`There's no saved search called "@${name}".`); + return `${before}${expandSavedSearches(saved, lookup, [...seen, name.toLowerCase()])}`; + }), + ) + .join('"'); +} + /** `>=4` as a value: a comparison written the way `amount:>500` always has been. */ const OP_VALUE = /^(>=|<=|>|<)(.+)$/; diff --git a/src/node/cli-files.ts b/src/node/cli-files.ts index 853d55d..6558f66 100644 --- a/src/node/cli-files.ts +++ b/src/node/cli-files.ts @@ -7,6 +7,7 @@ import { assignments, resolveTarget } from "./cli-records.ts"; import { attachFile, findFile, formatBytes, joinFile, listFiles, openInApp, openableFile, setFileFields, sizeReport } from "./roll-files.ts"; import type { FileInfo } from "./roll-files.ts"; import type { GitRoll } from "./repo.ts"; +import { expandQuery } from "./user-config.ts"; type Values = Record; export interface Paint { @@ -26,7 +27,7 @@ export function filesCommand(roll: GitRoll, args: string[], v: Values, paint: Pa console.log(`Opened ${paint.bold(file)}${temporary ? paint.dim(" (joined from its parts and checked against its sha256; a temporary copy)") : ""}`); return; } - const all = listFiles(roll, { query: args.join(" "), unfiled: !!v.unfiled }); + const all = listFiles(roll, { query: expandQuery(args.join(" ")), unfiled: !!v.unfiled }); const offset = Number(v.offset ?? 0); const page = all.slice(offset, v.limit === undefined ? undefined : offset + Number(v.limit)); if (v.json) return console.log(JSON.stringify(page, null, 2)); diff --git a/src/node/cli-records.ts b/src/node/cli-records.ts index c63ac5c..5cf80fc 100644 --- a/src/node/cli-records.ts +++ b/src/node/cli-records.ts @@ -14,6 +14,7 @@ import { ConflictError, NotFoundError } from "../core/util.ts"; import { CliError } from "./cli-contract.ts"; import type { GitRoll, SaveResult } from "./repo.ts"; import { maskEntry } from "./sealing.ts"; +import { expandQuery } from "./user-config.ts"; type Values = Record; @@ -49,7 +50,7 @@ export function recordTable(roll: GitRoll, name: string, query: string, values: throw new NotFoundError(`There's no collection called "${name}".${all.length ? ` Collections: ${all.map((c) => c.name).join(", ")}` : ' Start one with: gitroll add books "The Dispossessed"'}`); } const records = recordsIn(notes, name); - const matched = query.trim() ? new SearchIndex(records).search(query) : records; + const matched = query.trim() ? new SearchIndex(records).search(expandQuery(query)) : records; const sorted = sortedBy(matched, values.sort as string | undefined); const columns = values.fields === undefined ? columnsOf(records) : fieldList(String(values.fields)); const offset = Number(values.offset ?? 0); @@ -93,7 +94,7 @@ export function resolveTarget(roll: GitRoll, target: string): LoadedEntry { return roll.entry(target); } catch (e) { if (!(e instanceof NotFoundError)) throw e; - const hits = new SearchIndex(roll.documents()).search(target); + const hits = new SearchIndex(roll.documents()).search(expandQuery(target)); if (hits.length === 1) return hits[0]; if (!hits.length) throw e; throw new CliError("INVALID_ARGUMENT", `"${target}" matches ${hits.length} documents; name one: ${hits.slice(0, 5).map((h) => h.path).join(", ")}${hits.length > 5 ? ", โ€ฆ" : ""}`); diff --git a/src/node/cli-views.ts b/src/node/cli-views.ts index 9a57f30..63af1ac 100644 --- a/src/node/cli-views.ts +++ b/src/node/cli-views.ts @@ -17,7 +17,7 @@ import { ORGANIZATIONS_COLLECTION, organizations } from "../core/organizations.t import type { Organizations } from "../core/organizations.ts"; import { PLACES_COLLECTION, places } from "../core/places.ts"; import type { Places } from "../core/places.ts"; -import { issues } from "../core/issues.ts"; +import { issues, withoutResolved } from "../core/issues.ts"; import type { Issues } from "../core/issues.ts"; import { INVENTORY_COLLECTION, inventory, restockTodos } from "../core/inventory.ts"; import type { DerivedTodo, Inventory } from "../core/inventory.ts"; @@ -32,13 +32,16 @@ import type { Series, SeriesBy } from "../core/series.ts"; import { NotFoundError, UserError, formatAmount, isoDate } from "../core/util.ts"; import { CliError } from "./cli-contract.ts"; import { resolveTarget, sortedBy } from "./cli-records.ts"; +import { sidecarEntries } from "./roll-files.ts"; import type { GitRoll } from "./repo.ts"; +import { expandQuery } from "./user-config.ts"; type Values = Record; type Paint = (s: string) => string; const plain: Paint = (s) => s; -const filtered = (docs: LoadedEntry[], query: string): LoadedEntry[] => (query.trim() ? new SearchIndex(docs).search(query) : docs); +/** The documents a query finds, `@name` saved searches included; all of them when there is no query. */ +const filtered = (docs: LoadedEntry[], query: string): LoadedEntry[] => (query.trim() ? new SearchIndex(docs).search(expandQuery(query)) : docs); // โ”€โ”€ Calendar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -50,14 +53,25 @@ export function daysOption(v: Values): number { return Number(s); } +/** + * What the calendar is read from: events, notes and files' sidecars (a + * passport's `expires:`), and their to-dos, less what a resolved issue no + * longer puts on it (its open to-dos, reminders and repeats). + */ +export function calendarSources(roll: GitRoll): { docs: LoadedEntry[]; todos: ReturnType } { + return withoutResolved([...roll.documents(), ...sidecarEntries(roll)], roll.todos()); +} + /** `gitroll upcoming`: what's due from today to --days ahead, by date, with reminders; due reminders and overdue to-dos first. */ export function upcomingItems(roll: GitRoll, days: number, today = isoDate(), now = new Date()): CalendarItem[] { - return upcomingWithReminders(roll.documents(), roll.todos(), today, days, now); + const { docs, todos } = calendarSources(roll); + return upcomingWithReminders(docs, todos, today, days, now); } /** `gitroll reminders`: due ones first, then those in the next --days; with --due, only the due ones. */ export function reminderList(roll: GitRoll, days: number, dueOnly: boolean, now = new Date()): Reminder[] { - return reminders(roll.documents(), roll.todos(), { now, to: addDays(isoDate(now), days), dueOnly }); + const { docs, todos } = calendarSources(roll); + return reminders(docs, todos, { now, to: addDays(isoDate(now), days), dueOnly }); } export function formatReminders(list: Reminder[], paint: { bold: Paint; dim: Paint; red: Paint } = { bold: plain, dim: plain, red: plain }): string { @@ -76,8 +90,8 @@ export function formatReminders(list: Reminder[], paint: { bold: Paint; dim: Pai * (repeats are left to the rrule), and events dated today or later. */ export function calendarAll(roll: GitRoll, today = isoDate()): CalendarItem[] { - const docs = roll.documents(); - const starts = calendarItems(docs.filter((d) => !d.path.startsWith(".gitroll/events/") || hasStart(d)), roll.todos(), {}); + const { docs, todos } = calendarSources(roll); + const starts = calendarItems(docs.filter((d) => !d.path.startsWith(".gitroll/events/") || hasStart(d)), todos, {}); const ahead = calendarItems(docs.filter((d) => d.path.startsWith(".gitroll/events/") && !hasStart(d)), [], { from: today }); // A birthday with no year (vCard's --MMDD) has no first time to list, so the next one is. const yearless = calendarItems(docs.filter((d) => !d.path.startsWith(".gitroll/events/")), [], { from: today, to: addDays(today, 364) }).filter((i) => i.recurrence === "every year" && i.years === undefined); @@ -88,7 +102,8 @@ const hasStart = (d: LoadedEntry) => Object.keys(d.meta).some((k) => k.toLowerCa /** `gitroll calendar --ics`: the Roll's calendar as an RFC 5545 file. */ export function calendarIcs(roll: GitRoll, today = isoDate()): string { - return toICalendar(roll.documents(), roll.todos(), { name: roll.config().name, today }); + const { docs, todos } = calendarSources(roll); + return toICalendar(docs, todos, { name: roll.config().name, today }); } const KIND: Record = { event: "", occurrence: "repeats", todo: "to-do", field: "", reminder: "reminder" }; @@ -111,7 +126,8 @@ const shortPath = (p: string) => p.replace(/^\.gitroll\/events\//, "").replace(/ export function ledgerView(roll: GitRoll, query: string, by: string | undefined): Ledger { if (by !== undefined && !/^[A-Za-z_][\w-]*$/.test(by)) throw new CliError("INVALID_ARGUMENT", "--by takes month, year, project, tag or a field name, e.g. --by month"); - return ledger(filtered(roll.documents(), query), by); + const docs = roll.documents(); + return ledger(filtered(docs, query), by, docs); } export function hledgerJournal(view: Ledger): string { @@ -185,7 +201,7 @@ export function formatSeries(view: Series, paint: { bold: Paint; dim: Paint } = // โ”€โ”€ Inventory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function inventoryView(roll: GitRoll, query: string, values: Values, today = isoDate()): Inventory { - const name = typeof values.collection === "string" ? values.collection : INVENTORY_COLLECTION; + const name = collectionOption(values, INVENTORY_COLLECTION); const by = typeof values.by === "string" ? values.by : undefined; if (by !== undefined && !/^[A-Za-z_][\w-]*$/.test(by)) throw new CliError("INVALID_ARGUMENT", "--by takes location or a field name, e.g. --by location"); const docs = roll.documents(); @@ -282,7 +298,8 @@ export function formatOrganizations(view: Organizations, paint: { bold: Paint; d /** `gitroll places [query]`: the records in notes/places/ (or --collection) as a tree, with what is at each and what happened there. */ export function placesView(roll: GitRoll, query: string, values: Values): Places { const name = collectionOption(values, PLACES_COLLECTION); - return places(filtered(recordsIn(roll.notes(), name), query), roll.documents(), name); + // A file is somewhere when its sidecar links to the place. + return places(filtered(recordsIn(roll.notes(), name), query), [...roll.documents(), ...sidecarEntries(roll)], name); } export function formatPlaces(view: Places, paint: { bold: Paint; dim: Paint } = { bold: plain, dim: plain }): string { diff --git a/src/node/cli.ts b/src/node/cli.ts index c64b50b..77b43f9 100755 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -7,7 +7,7 @@ import { addRecordIdempotent, assignments, formatTable, listCollections, recordT import { calendarAll, calendarIcs, contactsView, daysOption, derivedTodos, formatContacts, formatInventory, formatIssues, formatLedger, formatOrganizations, formatPlaces, formatReminders, formatSeries, formatUpcoming, hledgerJournal, importCsv, importVcf, inventoryView, issuesView, label, ledgerView, organizationsView, placesView, recordsCsv, reminderList, seriesView, upcomingItems } from "./cli-views.ts"; import { reminderTime } from "../core/reminders.ts"; import { attachCommand, fileForSet, filesCommand, reassembleCommand, setFileCommand, sizeChecks } from "./cli-files.ts"; -import { searchRoll, wholeFile } from "./roll-files.ts"; +import { searchRoll, sidecarEntries, wholeFile } from "./roll-files.ts"; import { AGENT_GUIDE, AGENTS_MD_PATH, agentsMarkdown } from "./agent-guide.ts"; import { runMcpServer } from "./mcp.ts"; import { runAgentKey, runVerify, signatureLabel, signingChecks } from "./cli-verify.ts"; @@ -28,7 +28,7 @@ import type { EntryChanges, LoadedEntry } from "../core/layout.ts"; import { TEMPLATES_DIR, errorsOnly, findEntry, isNote } from "../core/layout.ts"; import { todosIn } from "../core/todos.ts"; import { isPinned, pinnedFirst } from "../core/pins.ts"; -import { isIssue } from "../core/issues.ts"; +import { isIssue, resolvedIssues } from "../core/issues.ts"; import { SearchIndex, facets } from "../core/search.ts"; import { codeRefs, refLabel, sourceRef } from "../core/code.ts"; import { related } from "../core/relations.ts"; @@ -41,7 +41,7 @@ import type { FileInput, SyncResult } from "./repo.ts"; import { serve } from "./server.ts"; import { commands, detectInstall, downloadVerified, latestVersion, newer, run } from "./install.ts"; import type { Install } from "./install.ts"; -import { addRoll, configDir, findRoll, loadUserConfig, removeSearch, rollKey, rollsHome, saveSearch, saveUserConfig } from "./user-config.ts"; +import { addRoll, configDir, expandQuery, findRoll, loadUserConfig, removeSearch, rollKey, rollsHome, saveSearch, saveUserConfig } from "./user-config.ts"; import { safeRead } from "./fs-safe.ts"; import { activateExisting, captureDestination, captureRolls, clearSingleton, openCaptureWindow, setCaptureDestination, startCaptureService, writeSingleton } from "./capture.ts"; import { captureDraft } from "./drafts.ts"; @@ -100,7 +100,7 @@ Events progress, learning, journal, maintenance, purchase (gitroll templates) --code records the repository, branch and commit you're on find "words" Also: project:house tag:payment after:2026-01-01 amount:>500 has:receipt - [--save ] [--all] Keep a search to reuse as @name, or search every Roll you have + [--save ] [--all] Keep a search to reuse as @name in any query, or search every Roll you have Searches what you wrote โ€” words, topics, tags, amounts, front matter and attached file names โ€” not what's inside those files, and not deleted events today | recent [-n 20] Events from today, or the latest ones @@ -118,7 +118,8 @@ Notes and to-dos notes ["words"] Your notes, by title, with how many to-dos each has open todo "call the plumber" [--to ] Add a to-do: "- [ ] โ€ฆ" at the end of notes/todo.md, or the note you name - todos ["query"] [--all] Every open to-do in every event and note (--all includes finished ones) + todos ["query"] [--all] Every open to-do in every event and note (--all includes finished ones, + and those of a resolved issue) done Tick one off. undone puts it back. Either is an ordinary edit, kept in history. Pins and issues @@ -580,7 +581,7 @@ async function main(argv: string[]): Promise { case "find": case "search": { const selected = v.all ? undefined : openRoll(); - const query = savedQuery(need(args.join(" "), 'gitroll find "words"')); + const query = expandQuery(need(args.join(" "), 'gitroll find "words"')); if (v.save) { const key = saveSearch(v.save, query); if (!v.json) console.log(`${green("Saved")} that search as ${bold(`@${key}`)}. Use it with: gitroll find @${key}`); @@ -638,7 +639,8 @@ async function main(argv: string[]): Promise { for (const ref of codeRefs(e)) { console.log(` ${dim(`${refLabel(ref.kind)}:`)} ${ref.text}${ref.url ? dim(` ${ref.url}`) : ""}`); } - const rel = related(e, roll.documents()); + // A file's sidecar that links here is linked from it too. + const rel = related(e, [...roll.documents(), ...sidecarEntries(roll)]); for (const x of rel.links) console.log(` ${dim("links to:")} ${eventName(x.path)} ${x.title}`); for (const x of rel.backlinks) console.log(` ${dim("linked from:")} ${eventName(x.path)} ${x.title}`); console.log(dim(` ${e.path}${e.date ? ` ยท dated from the ${e.dateFrom === "metadata" ? "front matter" : "file name"}` : " ยท undated"}`)); @@ -766,7 +768,8 @@ async function main(argv: string[]): Promise { const roll = openRoll(); const entries = roll.documents(); const e = findEntry(entries, need(args[0], "gitroll related ")); - const { links, backlinks, missing } = related(e, entries); + // A file's sidecar that links here is linked from it too. + const { links, backlinks, missing } = related(e, [...entries, ...sidecarEntries(roll)]); if (v.json) { return console.log(JSON.stringify({ links: links.map((x) => x.path), backlinks: backlinks.map((x) => x.path), missing }, null, 2)); } @@ -789,7 +792,7 @@ async function main(argv: string[]): Promise { // โ”€โ”€ Notes and to-dos โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ case "notes": { const roll = openRoll(); - const query = args.join(" ").trim(); + const query = expandQuery(args.join(" ").trim()); const notes = query ? new SearchIndex(roll.notes()).search(query) : roll.notes(); if (v.json) return console.log(JSON.stringify(pageEntries(notes.map((e) => maskEntry(e)), v), null, 2)); if (!notes.length) { @@ -932,9 +935,11 @@ async function main(argv: string[]): Promise { } case "todos": { const roll = openRoll(); - const query = args.join(" ").trim(); + const query = expandQuery(args.join(" ").trim()); const docs = query ? new Set(searchRoll(roll, query).map((e) => e.path)) : null; - const todos = [...roll.todos().filter((t) => (v.all || !t.done) && (!docs || docs.has(t.path))), ...derivedTodos(roll, docs)]; + // A resolved issue's open to-dos are dealt with: --all still lists them. + const resolved = v.all ? new Set() : resolvedIssues(roll.documents()); + const todos = [...roll.todos().filter((t) => (v.all || (!t.done && !resolved.has(t.path))) && (!docs || docs.has(t.path))), ...derivedTodos(roll, docs)]; if (v.json) return console.log(JSON.stringify(todos, null, 2)); if (!todos.length) { console.log(v.all ? "No to-dos anywhere in this Roll." : "Nothing to do."); @@ -2147,15 +2152,6 @@ function openEditor(start: string, ext = ".md"): string { } } -/** `@name` means a search someone saved earlier; anything else is the query itself. */ -function savedQuery(query: string): string { - const m = /^@([\w-]+)$/.exec(query.trim()); - if (!m) return query; - const saved = loadUserConfig().searches?.[rollKey(m[1])]; - if (!saved) throw new UserError(`There's no saved search called "@${m[1]}". See: gitroll searches`); - return saved; -} - /** The same search across every Roll on this computer. */ function findEverywhere(query: string, values: Record): void { const config = loadUserConfig(); diff --git a/src/node/server.ts b/src/node/server.ts index c055417..8ce235b 100644 --- a/src/node/server.ts +++ b/src/node/server.ts @@ -19,12 +19,12 @@ import { assignments } from "./cli-records.ts"; import { safeRead } from "./fs-safe.ts"; import { GitError, GitRoll, HARD_MAX_ATTACHMENT_MB, assetDir, isRepo } from "./repo.ts"; import type { FileInput, SyncResult, SyncStage } from "./repo.ts"; -import { listFiles, searchRoll, wholeFile } from "./roll-files.ts"; +import { listFiles, searchRoll, sidecarEntries, wholeFile } from "./roll-files.ts"; import { calendarIcs } from "./cli-views.ts"; import { once } from "node:events"; import { SEALED_SUFFIX } from "../core/sealed.ts"; import { hasIdentity, maskEntry, openSealedFile } from "./sealing.ts"; -import { loadUserConfig, removeSearch, renameSearch, saveSearch } from "./user-config.ts"; +import { expandQuery, loadUserConfig, removeSearch, renameSearch, saveSearch } from "./user-config.ts"; export const WEB_DIR = assetDir("index.html", "./web/", "../../dist/web/"); const MAX_BODY = HARD_MAX_ATTACHMENT_MB * 4 * 1024 * 1024; // base64 adds a third; allow a few large files @@ -276,7 +276,7 @@ async function api(ctx: Context, method: string, [resource, id, sub]: string[], return n; // changed under us into something unreadable: shown as loaded, not editable } }); - return sendJson(res, 200, { notes, revisions, todos: repo.todos(), files: listFiles(repo) }); + return sendJson(res, 200, { notes, revisions, todos: repo.todos(), files: listFiles(repo), sidecars: sidecarEntries(repo) }); } // Writing from those pages: each is the command line's own path. // `gitroll note`: @@ -377,7 +377,7 @@ async function api(ctx: Context, method: string, [resource, id, sub]: string[], // `gitroll find "โ€ฆ" --all`: the same search over every Roll on this // computer, this one first. Read-only; nothing in any Roll changes. case "GET rolls": - return sendJson(res, 200, { rolls: searchEverywhere(ctx.repo, searchText(params.get("q"))) }); + return sendJson(res, 200, { rolls: searchEverywhere(ctx.repo, expandQuery(searchText(params.get("q")))) }); // Following a result into another Roll opens that Roll's app, as `gitroll // open ` would, in this process and with its own access key. case "POST rolls/:id/open": diff --git a/src/node/user-config.ts b/src/node/user-config.ts index c7a591d..f837594 100644 --- a/src/node/user-config.ts +++ b/src/node/user-config.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { expandSavedSearches } from "../core/search.ts"; import { ConflictError, NotFoundError, UserError, slugify } from "../core/util.ts"; export interface UserConfig { @@ -138,3 +139,23 @@ function searchKey(name: string): string { if (!key) throw new UserError("Please give the search a name, like open-incidents."); return key; } + +/** + * A query with every `@name` in it replaced by the search saved under that + * name, so a saved search works in any command that takes a query, beside + * other words and filters. One that isn't saved is an error, not a search that + * finds nothing. + */ +export function expandQuery(query: string): string { + if (!query.includes("@")) return query; + const searches = loadUserConfig().searches ?? {}; + try { + return expandSavedSearches(query, (name) => { + const key = slugify(name); + return Object.hasOwn(searches, key) ? searches[key] : undefined; + }); + } catch (e) { + if (e instanceof NotFoundError) throw new NotFoundError(`${e.message} See: gitroll searches`); + throw e; + } +} diff --git a/src/web/components/App.tsx b/src/web/components/App.tsx index 54d2513..78f72f2 100644 --- a/src/web/components/App.tsx +++ b/src/web/components/App.tsx @@ -25,6 +25,7 @@ import { OrganizationsPage } from "./OrganizationsPage.tsx"; import { PlacesPage } from "./PlacesPage.tsx"; import { IssuesPage } from "./IssuesPage.tsx"; import type { Issue } from "../../core/issues.ts"; +import { resolvedIssues } from "../../core/issues.ts"; import { LedgerPage } from "./LedgerPage.tsx"; import { SeriesPage } from "./SeriesPage.tsx"; import { NotesPage } from "./NotesPage.tsx"; @@ -35,7 +36,7 @@ import { ViewsState } from "./ViewParts.tsx"; import { QueryBar } from "./QueryBar.tsx"; import type { Scope } from "./QueryBar.tsx"; import { AllRollsResults, allRollsStatus, useAllRolls } from "./AllRolls.tsx"; -import { SavedSearchChips, useSavedSearches } from "./SavedSearches.tsx"; +import { QueryError, SavedSearchChips, SavedSearchesContext, savedQuery, useSavedSearches } from "./SavedSearches.tsx"; import { ShortcutsDialog } from "./ShortcutsDialog.tsx"; import { SyncIndicator, useSync } from "./SyncStatus.tsx"; import { Timeline } from "./Timeline.tsx"; @@ -61,12 +62,17 @@ export function App({ store }: { store: Store }) { const views = useViews(store, version, !!viewsStore && wantsViews); const notes = useMemo(() => views.data?.notes ?? [], [views.data]); const docs = useMemo(() => [...entries, ...notes], [entries, notes]); + // Files' sidecars, for what reads them as `gitroll` does: the calendar, places and what links to an entry. + const withFiles = useMemo(() => [...docs, ...(views.data?.sidecars ?? [])], [docs, views.data]); + const resolved = useMemo(() => resolvedIssues(docs), [docs]); const ask = useAsk(); // Saved searches and All Rolls are the app on this computer's to offer; // a store without them shows neither. const searchesStore = hasSavedSearches(store) ? store : null; const rollsStore = hasAllRolls(store) ? store : null; const saved = useSavedSearches(searchesStore, version); + // `@name` in any search box is that saved search, once they have been read. + const savedList = searchesStore && saved.loaded ? saved.list : null; const [scope, setScope] = useState("roll"); const everywhere = !!rollsStore && scope === "all"; @@ -77,7 +83,8 @@ export function App({ store }: { store: Store }) { const conflictCount = useMemo(() => entries.filter((e) => e.tags.includes("conflict")).length, [entries]); const query = route.name === "timeline" ? route.query : ""; - const results = useMemo(() => index.search(query), [index, query]); + const searched = useMemo(() => savedQuery(savedList, query), [savedList, query]); + const results = useMemo(() => index.search(searched.query), [index, searched.query]); const allRolls = useAllRolls(rollsStore, query, everywhere && route.name === "timeline", version); const totals = useMemo(() => { const map = new Map(); @@ -467,155 +474,159 @@ export function App({ store }: { store: Store }) { -
- + +
+ - {route.name === "timeline" && ( - <> -
- void save()} - saving={saving} - collapsible - expanded={composerOpen} - onExpandedChange={setComposerOpen} - /> -
- - - } - /> + {route.name === "timeline" && ( + <> +
+ void save()} + saving={saving} + collapsible + expanded={composerOpen} + onExpandedChange={setComposerOpen} + /> +
- {everywhere ? ( - - ) : ( - -

{COPY.noMatches}

-

{COPY.noMatchesHint}

- - - ) : ( -
-

{COPY.emptyTitle}

-

{COPY.emptyBody}

-
- ) + resultCount={results.length} + totals={totals} + inputRef={searchRef} + scope={rollsStore ? { value: scope, onChange: setScope } : undefined} + status={everywhere ? allRollsStatus(allRolls) : undefined} + extraFilters={ + searchesStore && } /> - )} - - )} + + + {everywhere ? ( + + ) : ( + +

{COPY.noMatches}

+

{COPY.noMatchesHint}

+ + + ) : ( +
+

{COPY.emptyTitle}

+

{COPY.emptyBody}

+
+ ) + } + /> + )} + + )} - {route.name === "topics" && void newTopic()} />} + {route.name === "topics" && void newTopic()} />} - {route.name === "conflicts" && } + {route.name === "conflicts" && } - {route.name === "deleted" && } + {route.name === "deleted" && } - {VIEW_PAGES.has(route.name) && - (!viewsStore ? ( -

This page isn't available here yet. The GitRoll app on your computer has it.

- ) : !views.data ? ( - - ) : ( - <> - {route.name === "notes" && } - {route.name === "records" && ( - - )} - {route.name === "upcoming" && ( - - )} - {route.name === "ledger" && } - {route.name === "series" && } - {route.name === "inventory" && } - {route.name === "contacts" && } - {route.name === "organizations" && } - {route.name === "places" && } - {route.name === "issues" && void resolveIssue(issue) : undefined} />} - {route.name === "files" && ( - store.attachmentUrl({ path, name: path, type: "", image: false })} /> - )} - - ))} + {VIEW_PAGES.has(route.name) && + (!viewsStore ? ( +

This page isn't available here yet. The GitRoll app on your computer has it.

+ ) : !views.data ? ( + + ) : ( + <> + {route.name === "notes" && } + {route.name === "records" && ( + + )} + {route.name === "upcoming" && ( + + )} + {route.name === "ledger" && } + {route.name === "series" && } + {route.name === "inventory" && } + {route.name === "contacts" && } + {route.name === "organizations" && } + {route.name === "places" && } + {route.name === "issues" && void resolveIssue(issue) : undefined} />} + {route.name === "files" && ( + store.attachmentUrl({ path, name: path, type: "", image: false })} /> + )} + + ))} - {route.name === "entry" && ( - { - if (!entry) return; - const kept = readDraft(rollKey, entry.path); - setEditing(entry); - setValue(fromDraft(kept) ?? valueFor(entry)); - if (kept) { - toast.toast(COPY.draftKept); - if (kept.attachments.length) toast.toast(COPY.draftFiles(kept.attachments)); - } - }} - onDelete={() => entry && void deleteEntry(entry)} - loadHistory={(id) => store.history(id)} - onRestore={async (commit) => { - if (!entry) return; - try { - await store.restoreVersion(entry.path, commit); - storeChanged(); - toast.toast("That version is back, saved as a new commit. The others are still in History."); - } catch (err) { - toast.error(message(err)); - } - }} - onPin={setPinned && entry ? (pinned) => setPinned(entry.path, pinned) : undefined} - onResolveIssue={resolveIssue && entry ? () => void resolveIssue(entry) : undefined} - /> - )} -
+ {route.name === "entry" && ( + { + if (!entry) return; + const kept = readDraft(rollKey, entry.path); + setEditing(entry); + setValue(fromDraft(kept) ?? valueFor(entry)); + if (kept) { + toast.toast(COPY.draftKept); + if (kept.attachments.length) toast.toast(COPY.draftFiles(kept.attachments)); + } + }} + onDelete={() => entry && void deleteEntry(entry)} + loadHistory={(id) => store.history(id)} + onRestore={async (commit) => { + if (!entry) return; + try { + await store.restoreVersion(entry.path, commit); + storeChanged(); + toast.toast("That version is back, saved as a new commit. The others are still in History."); + } catch (err) { + toast.error(message(err)); + } + }} + onPin={setPinned && entry ? (pinned) => setPinned(entry.path, pinned) : undefined} + onResolveIssue={resolveIssue && entry ? () => void resolveIssue(entry) : undefined} + /> + )} +
+ {/* Editing happens in a dialog: it is a detour from reading, and it should be obvious that leaving it without saving loses the change. */} diff --git a/src/web/components/ContactsPage.tsx b/src/web/components/ContactsPage.tsx index fc6ed8d..d94061f 100644 --- a/src/web/components/ContactsPage.tsx +++ b/src/web/components/ContactsPage.tsx @@ -6,6 +6,7 @@ import { SearchIndex } from "../../core/search.ts"; import { plural } from "../lib/format.ts"; import { DocLink, Empty, PageHeader, dayText, tableClass, tdClass, thClass } from "./ViewParts.tsx"; import { Input, Label } from "./ui/input.tsx"; +import { QueryError, useSavedQuery } from "./SavedSearches.tsx"; const linkClass = "rounded underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"; @@ -16,8 +17,9 @@ const linkClass = "rounded underline-offset-2 hover:underline focus-visible:outl */ export function ContactsPage({ notes, docs }: { notes: LoadedEntry[]; docs: LoadedEntry[] }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const records = useMemo(() => recordsIn(notes, PEOPLE_COLLECTION), [notes]); - const view = useMemo(() => contacts(query.trim() ? new SearchIndex(records).search(query) : records, docs), [records, docs, query]); + const view = useMemo(() => contacts(saved.query.trim() ? new SearchIndex(records).search(saved.query) : records, docs), [records, docs, saved.query]); if (!records.length) { return ( @@ -38,6 +40,7 @@ export function ContactsPage({ notes, docs }: { notes: LoadedEntry[]; docs: Load
setQuery(e.target.value)} placeholder="Words, or fields: org:acme has:bday" /> +

diff --git a/src/web/components/EntryDetail.tsx b/src/web/components/EntryDetail.tsx index f6db9af..b7acf0a 100644 --- a/src/web/components/EntryDetail.tsx +++ b/src/web/components/EntryDetail.tsx @@ -362,14 +362,16 @@ function Related({ entry, entries, notesRead }: { entry: LoadedEntry; entries: L const { links, backlinks } = all; const missing = notesRead ? all.missing : all.missing.filter((p) => !p.startsWith(".gitroll/notes/")); if (!links.length && !backlinks.length && !missing.length) return null; + // A file's sidecar links too; the file has no page of its own, so it opens the Files page. + const isFile = (e: LoadedEntry) => e.path.startsWith(".gitroll/files/"); const row = (e: LoadedEntry) => (

  • {e.title} - {e.path.startsWith(".gitroll/notes/") ? "note" : (e.date ?? "undated")} + {isFile(e) ? "file" : e.path.startsWith(".gitroll/notes/") ? "note" : (e.date ?? "undated")}
  • ); diff --git a/src/web/components/InventoryPage.tsx b/src/web/components/InventoryPage.tsx index 6475b2a..ffd8408 100644 --- a/src/web/components/InventoryPage.tsx +++ b/src/web/components/InventoryPage.tsx @@ -9,6 +9,7 @@ import { plural } from "../lib/format.ts"; import { DocLink, Empty, PageHeader, Totals, dayText, tableClass, tdClass, thClass } from "./ViewParts.tsx"; import { Button } from "./ui/button.tsx"; import { Input, Label } from "./ui/input.tsx"; +import { QueryError, useSavedQuery } from "./SavedSearches.tsx"; /** * What you own, from the records in .gitroll/notes/inventory/: what each is @@ -18,12 +19,13 @@ import { Input, Label } from "./ui/input.tsx"; */ export function InventoryPage({ notes, docs }: { notes: LoadedEntry[]; docs: LoadedEntry[] }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const [byPlace, setByPlace] = useState(false); const records = useMemo(() => recordsIn(notes, INVENTORY_COLLECTION), [notes]); const view = useMemo(() => { - const chosen = query.trim() ? new SearchIndex(records).search(query) : records; + const chosen = saved.query.trim() ? new SearchIndex(records).search(saved.query) : records; return inventory(chosen, docs, { by: byPlace ? "location" : undefined, today: isoDate() }); - }, [records, docs, query, byPlace]); + }, [records, docs, saved.query, byPlace]); if (!records.length) { return ( @@ -71,6 +73,7 @@ export function InventoryPage({ notes, docs }: { notes: LoadedEntry[]; docs: Loa
    setQuery(e.target.value)} placeholder="Words, or fields: brand:daikin price>100" /> +