diff --git a/CHANGELOG.md b/CHANGELOG.md index df95cba..fd0e72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 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.** When the event that bought a thing links to it, in its text or a front matter field, the thing's own `price` is no longer added on top of it. That event is the one with an `amount` dated on the thing's `purchaseDate`, or, when it has none, the one whose amount is its price exactly, in the same currency; a service or a repair that links to it with an amount of its own leaves the price counted. A thing with a price and no purchase 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**. +- **An organization lists what it supplied.** A thing whose `vendor:` links to an organization — the e-bike bought from Bolt Cycles — is listed with it: `supplied` in `gitroll organizations --json`, after its people in the text, and a **Supplied** column on **Organizations** in the browser app, as a place lists the things whose `location` links to it. An event's `vendor:` is still its history. SPEC.md adds `vendor` to the inventory vocabulary. +- **A contact whose `org` names an organization is linked to it.** `org: bolt` already made Sam one of Bolt Cycles' people, by its `alternateName`, but `gitroll contacts` gave him no `orgPath`. It is now the organization's record when exactly one in `.gitroll/notes/organizations/` goes by that name, and still null when none or two do. +- **`gitroll calendar --ics` and `calendar` leave out an event logged today.** SPEC.md puts events dated ahead of today on the calendar, and an event `gitroll log` writes is dated the moment it was logged: it happened, it isn't coming. An event dated today with no time, or at a time already past, is no longer listed as upcoming; one later today, or on a day ahead, still is. `upcoming` and the browser app's **Upcoming** read it the same way. +- **`gitroll contacts --vcf` writes a sealed `tel` opened when a key on this computer opens it.** It is the key holder's own address book going out. Without a key the property is still left out, never written as ciphertext, and now the command says which ones (on stderr, so the file on stdout is untouched; `sealed` in `--json`). +- **`gitroll records --csv` says when it writes sealed fields.** They go out as their ciphertext, as they always did, so the CSV opens nothing and `gitroll import csv` brings them back sealed; the command now says so on stderr, `--json` lists them in `sealed`, and docs/CLI.md and SPEC.md describe it. +- **`edit --text` keeps the `# Title` heading.** New words replaced the whole text, heading included, so editing a contact's notes could rename them. The heading stays unless the new text starts with a `# ` heading of its own. The agent guide says so (version 10). + +### 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. +- **Editing a file keeps its own flow lists.** `set`, `pin`, `seal`, `attach --to`, `edit` and `move` rewrote `projects: [bike]` as `projects: [ bike ]`, the YAML library's own spacing, in every file they touched. Each file now keeps the spacing its flow lists already have, `[bike]` or `[ bike ]`, and a list added to it is written the same way; a new file is written as before. +- **What a write returns never holds ciphertext.** `set`, `pin`, `edit`, `todo`, `done`, `close`, `move`, `restore`, `attach` and the rest returned the entry with each sealed field's armored text in `meta`, on the command line with `--json` and over MCP, while `show` and `find` gave `{"sealed": true}`. They now return it as a read does. The files are unchanged; `gitroll files --json` masks a sidecar's sealed fields too. +- **An `org` that links to an organization and names a unit is a link everywhere.** `org: "[Acme](../organizations/acme.md);Research"` made Ada one of Acme's people but, not being exactly a link, wasn't one to `gitroll related`, backlinks or `check`, and `move` didn't count it among the links it rewrote. Its link part now counts wherever a front matter link does. +- **A link in a field or a to-do shows as its text.** `gitroll records`, `show`, `set`, `todos`, `upcoming` and `reminders`, an entry's fields and **Upcoming**'s to-dos in the browser app showed `[Maria](../people/maria.md)` as written, while the browser app's records table showed `Maria`. They all show the text now, and in the browser app it is a link to the note or page; `--json` keeps the value as written. + ## 0.7.0 (2026-10-07) ### Added diff --git a/README.md b/README.md index 926f8ee..3455c8e 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 linked event is its purchase), 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..bc5a0ec 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 @@ -216,7 +218,7 @@ A reader that supports searching should understand: - `has:key` — the field has something in it: present, and not empty, an empty list or `false`. - Keys match in any case. A few names keep the meaning they have always had in a search: `project`/`topic`, `tag`, `after`, `before`, `on`/`date`, `amount`, `has` and `is`; `title`, `date`, `amount`, `tags` and `projects` mean what a reader computes for the document (so `date` falls back to the file name). -A writer that sets a field edits the YAML in place: other keys, their order, comments and formatting, and the body are left as they were. A value typed as text is parsed as a YAML scalar, so its type is the one YAML gives it — except a Markdown link (`[House](house.md)`, or one with more after it, as `org` has), which is written as the text it is: as YAML it would be a list followed by something else. +A writer that sets a field edits the YAML in place: other keys, their order, comments and formatting, and the body are left as they were. That includes the spacing of flow lists: a file that writes `[bike]` keeps `[bike]`, one that writes `[ bike ]` keeps `[ bike ]`, and a list the writer adds is written the way the file's existing flow lists are. A value typed as text is parsed as a YAML scalar, so its type is the one YAML gives it — except a Markdown link (`[House](house.md)`, or one with more after it, as `org` has), which is written as the text it is: as YAML it would be a list followed by something else. ### Series @@ -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 (an event with no `start` dated today is ahead only at a time still to come: one with no time, or a time already past, as a writer records the moment it logs, is a record of what happened, not something coming), 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 @@ -362,11 +364,12 @@ Things are records with [schema.org](https://schema.org/Product) names for their | `price`, `priceCurrency` | What it cost each: a number and an ISO 4217 code, or money text like `$1,899` | | `warranty` | The day its warranty ends (a date) | | `location` | Where it is: text, or a link to a place record, `"[Garage](../places/garage.md)"` | +| `vendor` | Who it was bought from: text, or a link to an organization record, `"[Bolt Cycles](../organizations/bolt-cycles.md)"` | | `quantity`, `reorderAt` | How many are left, and the number at which to buy more | 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 its purchase is an event that links to the record, in its text or a front matter field, and has an `amount`: that event is the transaction, and counting the price too would count it twice. The purchase is the event dated on the record's `purchaseDate` (whatever it cost: a deposit, a discount), or, when the record has no `purchaseDate`, the event whose `amount` is its price exactly, in the same currency. Any other event that links to it with an amount (a service, a repair) is a transaction of its own and leaves the price counted. A thing with a price and no purchase event is counted by its price, and its value in the inventory is its price either way. ## Contact vocabulary @@ -379,7 +382,7 @@ People are records with [vCard](https://www.rfc-editor.org/rfc/rfc6350) (RFC 635 | `nickname` | `NICKNAME` | What they're called; a list for more than one | | `email`, `tel` | `EMAIL`, `TEL` | How to reach them: one value, or a list | | `adr` | `ADR` | An address in vCard's seven parts, split by `;`: PO box; extended; street; locality; region; postal code; country (`;;1 Main St;Springfield;IL;62701;USA`). A list for more than one | -| `org` | `ORG` | Where they work, then its units, split by `;` (`Acme;Research`). The name may be a link to an organization record, `"[Acme](../organizations/acme.md);Research"`; vCard gets its text | +| `org` | `ORG` | Where they work, then its units, split by `;` (`Acme;Research`). The name may be a link to an organization record, `"[Acme](../organizations/acme.md);Research"`, which is a link like any other front matter link (see **Links between events**); vCard gets its text | | `jobTitle` | `TITLE` | Their job title. schema.org's name, because `title` already means the record's own title | | `role` | `ROLE` | What they do there | | `bday`, `anniversary` | `BDAY`, `ANNIVERSARY` | A date (`1815-12-10`), or the month and day alone, as vCard writes them: `--1210` | @@ -390,7 +393,7 @@ Every other key is yours. Events that link to a person, in their text or in a fr A collection of people goes out as a vCard 4.0 file and comes in from a vCard 3.0 or 4.0 one: -- **Out**: a card per record: `VERSION:4.0`, `FN`, then a property per key above, in that order. Lines end in CRLF and are folded at 75 octets (never inside a character), continuing with a space. Text escapes `\`, `,`, `;` and line breaks (`\n`), and a structured value (`n`, `adr`, `org`) escapes each part and joins them with `;`. Dates use vCard's basic form, `18151210` or `--1210`; a `bday` that isn't a date is written `BDAY;VALUE=text:`. +- **Out**: a card per record: `VERSION:4.0`, `FN`, then a property per key above, in that order. A sealed value is never written as ciphertext: a writer holding a key that opens it writes it opened (the person with the key is exporting their own address book), and otherwise leaves the property out and says so. Lines end in CRLF and are folded at 75 octets (never inside a character), continuing with a space. Text escapes `\`, `,`, `;` and line breaks (`\n`), and a structured value (`n`, `adr`, `org`) escapes each part and joins them with `;`. Dates use vCard's basic form, `18151210` or `--1210`; a `bday` that isn't a date is written `BDAY;VALUE=text:`. - **In**: a record per card, titled by its `FN` (else its `N`, its `ORG` or its `EMAIL`), with a field for each property above; a property given more than once is a list, except the ones vCard allows only once (`N`, `BDAY`, `ANNIVERSARY`, `GENDER`, `UID`). Parameters (`TYPE=work`) are not kept, and properties without a key here (`PHOTO`, `X-…`) are left out. A date comes in as `1815-12-10` or `--1210` (Apple's `X-APPLE-OMIT-YEAR` is read as no year). - **Once only**: each record written by an import has `source: {adapter: vcf, id: }`, and a card whose id is already in the collection is skipped, so importing the same file twice creates each person once. @@ -409,7 +412,7 @@ Organizations are records with [schema.org](https://schema.org/Organization) Org | `parentOrganization` | A link to the organization it is part of: `"[Acme](acme.md)"` | | `location` | A link to a place record, as for a thing | -Every other key is yours. An organization's people are not listed in it: they are the records whose `org` (see **Contact vocabulary**) is a link to it, or whose `org` names it — its title, `legalName` or an `alternateName`, ignoring case — so a person's employer is written once, on the person, where vCard already puts it. Its sub-organizations are the records whose `parentOrganization` links to it. Events that link to it, in their text or in a front matter field (`vendor: "[Acme](../../notes/organizations/acme.md)"`), are its history, read as backlinks, and the newest one is when it was last contacted. +Every other key is yours. An organization's people are not listed in it: they are the records whose `org` (see **Contact vocabulary**) is a link to it, or whose `org` names it — its title, `legalName` or an `alternateName`, ignoring case — so a person's employer is written once, on the person, where vCard already puts it. A person whose `org` names exactly one organization in `.gitroll/notes/organizations/` is read as linked to that record; one it names twice is linked to neither. Its sub-organizations are the records whose `parentOrganization` links to it, and what it supplied the notes (things, usually) whose `vendor` links to it. Events that link to it, in their text or in a front matter field (`vendor: "[Acme](../../notes/organizations/acme.md)"`), are its history, read as backlinks, and the newest one is when it was last contacted. Why these: schema.org is what the inventory already uses for things, and its Organization names are the ones search engines and the JSON-LD on organizations' own websites use. vCard can describe an organization (`KIND:org`), but has no names for what it is part of, when it began or where else it is described, which schema.org has. Reading a person's existing `org` rather than adding a list of members keeps a contact's vCard exactly what it was, and the membership in one place. @@ -425,7 +428,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. @@ -433,7 +436,7 @@ Why these: `latitude` and `longitude` are schema.org Place's own properties, and A collection goes out to, and comes in from, a spreadsheet as CSV (RFC 4180): comma-separated, CRLF between rows, and a field holding a comma, a double quote or a line break in double quotes, each quote inside doubled. -- **Out**: a header row of `title` and then every field in use, one row per record. A list is written `[a, b]` (YAML's flow style), a mapping as JSON, anything else as written. +- **Out**: a header row of `title` and then every field in use, one row per record. A list is written `[a, b]` (YAML's flow style), a mapping as JSON, anything else as written. A sealed field is written as its armored ciphertext, exactly as it is in the file: an export opens nothing, so the file is no more readable than the Roll, and an import brings it back sealed. A writer says when an export holds sealed fields. - **In**: one record per row. The `title` column, else a `name` column, else the first, is the record's title; every other column whose header is a field name is a field. A cell that is a number (with no leading zero), `true` or `false`, or a `[list]` becomes one; anything else, dates included, is text. Empty cells write nothing. - **Once only**: each record written by an import has `source: {adapter: csv, id: }`, the same mapping importers of events use, and a row whose id is already in the collection is skipped, so importing the same file twice creates each record once. @@ -447,7 +450,7 @@ Follows [the incident on the 14th](2026-09-14-checkout-timeouts.md). That link is the relationship, and the backlink is the same link read the other way round — worked out when it is needed, never stored. Nothing declares a relationship, and the link still resolves on GitHub and in a text editor. -A front matter value that is exactly a Markdown link (`with: "[Ada](../notes/people/ada.md)"`, `location:`, `resolves:`) is a link in the same way, resolved against the document it is in: what a document links to, and what links back to it, are read from its text and its front matter alike. `gitroll check` reports a link in either that leads to an event or a note that isn't there. +A front matter value that is exactly a Markdown link (`with: "[Ada](../notes/people/ada.md)"`, `location:`, `resolves:`) is a link in the same way, resolved against the document it is in; so is the link at the start of a value that goes on with `;` and more parts, as `org: "[Acme](../organizations/acme.md);Research"` does: what a document links to, and what links back to it, are read from its text and its front matter alike. `gitroll check` reports a link in either that leads to an event or a note that isn't there. ## Pins @@ -506,6 +509,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 @@ -639,6 +644,7 @@ What a reader does with it: - A reader with a key may show it opened, but **never writes the plaintext back** unless the person explicitly asks to unseal it. - **Search never indexes sealed content**, neither its ciphertext nor its plaintext. - A writer keeps a sealed block or field byte for byte when it edits anything else in the file, so sealed parts survive ordinary edits, and merge line by line like any text. +- A writer that reports what it wrote reports it as a reader does: a sealed field is `{"sealed": true}` there too, never its ciphertext. Sealing changes the current file only. **Text committed in plain before it was sealed is still in Git history**: a writer that seals something must say so, naming the commits, and must not rewrite history on its own. @@ -647,7 +653,7 @@ Sealing changes the current file only. **Text committed in plain before it was s - **Identity** is the file's path. It is readable, typeable, and needs nothing generated. - **Renames and moves** are ordinary Git renames. A writer that moves an event or a note rewrites the relative links in it, in its body and its front matter, so they still resolve, and the links to it in every other event, note and sidecar, in their text or their front matter (a `resolves:`, a `location:`, an `org:`), so they still lead to it. Git history follows the file. - **Filename collisions**: a writer appends `-2`, `-3`, … before the extension. Two events logged the same day about the same thing become `2026-09-15-ac-serviced.md` and `2026-09-15-ac-serviced-2.md`. Nothing is overwritten, ever. -- **Edits** rewrite the file in place, each in its own commit. Git history is the audit trail: previous versions are never rewritten or force-pushed away by GitRoll. +- **Edits** rewrite the file in place, each in its own commit. A writer that replaces a document's text keeps the `# Title` heading it starts with, unless the new text starts with a `# ` heading of its own, so new words don't rename it by accident. Git history is the audit trail: previous versions are never rewritten or force-pushed away by GitRoll. - **Simultaneous edits** are merged as Markdown, line by line, the way Git merges any text file. That succeeds whenever two people touched different parts of the file. When the same lines changed on both sides, this device's version is kept as it is and the other version is appended in a note tagged `#conflict`, so nothing is lost and the conflict is easy to find. - **Authors** come from Git: `git log` and `git blame` know who wrote what. Events carry no author field, so nobody can sign as someone else by editing a file. - **Agents** are recorded the same way. A writer acting for an AI agent ends the commit message with a Git trailer, `Gitroll-Agent: ` (one line), and writes nothing about it into the file. Readers that show history may show it; nothing else depends on it. diff --git a/docs/CLI.md b/docs/CLI.md index d88a3db..22dee19 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 @@ -156,7 +168,11 @@ gitroll import csv inventory inventory.csv --dry-run -C /path/to/roll --json `records --csv` prints the collection as RFC 4180 CSV (CRLF, quoted where needed): a `title` column, then one per field, honouring a query, -`--sort` and `--fields`. With `--json` it returns `{collection, csv}`. +`--sort` and `--fields`. With `--json` it returns `{collection, csv, sealed}`. +A sealed field is written as its age ciphertext, never opened: the CSV is no +more readable than the Roll, and `import csv` brings it back sealed. `sealed` +lists the columns that hold any, and without `--json` the command says so on +stderr, leaving the CSV on stdout as it is. `import csv ` (`-` reads stdin) adds a record per row in one commit: the `title` or `name` column (else the first) is the title, other columns are fields, and a number, `true`/`false` or `[list]` cell is typed as @@ -186,7 +202,12 @@ gitroll import vcf people.vcf --dry-run -C /path/to/roll --json `lastContacted` (the newest one's date). `--collection ` reads another collection. - `contacts --vcf` prints a vCard 4.0 file of the same people: CRLF, folded at - 75 octets, escaped text. With `--json`, the view plus `vcf`. + 75 octets, escaped text. With `--json`, the view plus `vcf` and `sealed`. + A sealed property (`tel`, say) is written opened when a key on this + computer opens it: the person holding the key is exporting their own + address book. Without one it is left out of the card, never written as + ciphertext; `sealed` lists each one left out as `{path, field}`, and + without `--json` the command says so on stderr. - `import vcf ` makes a record per card, from vCard 3.0 or 4.0, in `notes/people/` (or `--collection`). Each gets `source: {adapter: vcf, id: }`, so a second import skips what is already there. It @@ -200,7 +221,11 @@ gitroll import vcf people.vcf --dry-run -C /path/to/roll --json - `org` may be a link to an organization record, `org: '[Acme](../organizations/acme.md)'` (units after it, `;Research`, as vCard writes them). `org` is then its text, and `orgPath` the record's path; - `--vcf` writes the text. + `--vcf` writes the text. An `org` that names an organization instead (its + title, `legalName` or an `alternateName`, ignoring case) has the `orgPath` + of the record in `notes/organizations/` that goes by that name, when exactly + one does, and null otherwise. The link part of `org` is a link like any + other front matter link: `related`, backlinks, `check` and `move` read it. ## Organizations and places @@ -224,8 +249,10 @@ gitroll places 'has:address' -C /path/to/roll --json link to a record in the Roll), `subOrganizations` (records whose `parentOrganization` links to it), `members` (`{path, name, jobTitle, units}`: every note whose `org` links to it, or names it, its `legalName` or - an `alternateName`, ignoring case), `interactions` (events that link to it, - in their text or a front matter field, newest first) and `lastContacted`. + an `alternateName`, ignoring case), `supplied` (`{path, title}`: every note, + such as an inventory record, whose `vendor` links to it, by title), + `interactions` (events that link to it, in their text or a front matter + field, newest first) and `lastContacted`. - `places [query]` returns `{collection, places}` in tree order: each place, then the places within it, by name. Each has `path`, `name`, `addresses`, `telephones`, `urls`, `coordinates` (`{latitude, longitude, uri}`, `uri` an @@ -233,10 +260,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 +310,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 +335,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 +379,13 @@ 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 its purchase links + to it, in its text or a front matter field: an event with an `amount` dated + on the record's `purchaseDate`, or, without one, whose `amount` is the price + exactly, in the same currency. That event is the transaction, counted once; + any other event linking to it with an amount (a service) leaves the price + counted. A thing with a price and no purchase 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 +454,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 @@ -493,6 +539,10 @@ or writing the edit. Read the current event and reconcile changes before retrying. This is an optimistic content check, not a filesystem transaction against external editors. +`edit --text` replaces the words and keeps the `# Title` heading the document +starts with, so new text can't rename a contact or a record by accident; text +that starts with a `# ` heading of its own replaces it. + Use explicit text for unattended edits. `log` also reads UTF-8 stdin when no text or attachments are supplied; close stdin after writing. `--editor` and `log --template` launch an editor and are rejected with JSON/noninteractive mode. @@ -674,8 +724,11 @@ removed key can still open it there. To remove it from history, see SECURITY.md, Keys come from `GITROLL_IDENTITY` (a path to an age identity file) or `keys.txt` in the settings folder. Without a key, nothing errors: -- `show --json`, `find --json` and every list return a sealed field as +- `show --json`, `find --json`, every list, and the entry every write returns + (`log`, `edit`, `note`, `set`, `pin`, `add`, `todo`, `done`, `close`, `move`, + `restore`, `resolve`, `attach`) give a sealed field as `{"sealed": true}` and add `sealed: [{sealed: true, kind: "field", field}, {sealed: true, kind: "block", lines}]`. + The file on disk keeps its ciphertext, as always. The body keeps the ciphertext block as it is, so an edit that sends the body back keeps it sealed. - With `--unsealed` and a key that opens it, each part also has `text`, and a diff --git a/src/core/calendar.ts b/src/core/calendar.ts index c516d63..5ff0eab 100644 --- a/src/core/calendar.ts +++ b/src/core/calendar.ts @@ -438,6 +438,23 @@ export interface CalendarOptions { to?: string; /** Include open dated to-dos from before `from`, marked overdue. */ overdue?: boolean; + /** Today (YYYY-MM-DD): an event dated today is listed only when it is at a time still to come (see eventAhead). */ + today?: string; + /** Now, for that time. The current time when omitted. */ + now?: Date; +} + +/** + * Whether an event with no `start` is still to come, and so on the calendar: + * dated a day after today, or today at a time not yet reached. One dated today + * with no time, or at a time already past (as `gitroll log` writes the moment + * it logs), is a record of what happened, not something coming. + */ +export function eventAhead(date: string, today: string, now: Date = new Date()): boolean { + const day = date.slice(0, 10); + if (day !== today) return day > today; + const at = date.length > 10 ? new Date(date).getTime() : Number.NaN; + return Number.isFinite(at) && at > now.getTime(); } /** @@ -466,7 +483,7 @@ export function calendarItems(entries: Entry[], todos: (Todo & { title?: string if (inRange(start)) out.push({ date: start, kind: "event", title: e.title, path: e.path, rrule: rruleText, problem: (err as Error).message, ...extra }); } } else if (inRange(start)) out.push({ date: start, kind: "event", title: e.title, path: e.path, ...extra }); - } else if (isEventPath(e.path) && e.date && inRange(e.date)) { + } else if (isEventPath(e.path) && e.date && inRange(e.date) && (!opts.today || eventAhead(e.date, opts.today, opts.now))) { out.push({ date: e.date, kind: "event", title: e.title, path: e.path, ...extra }); } for (const field of DUE_FIELDS) { @@ -505,6 +522,6 @@ export function calendarItems(entries: Entry[], todos: (Todo & { title?: string } /** `gitroll upcoming`: from today to `days` ahead, with open to-dos already overdue first. */ -export function upcoming(entries: Entry[], todos: (Todo & { title?: string })[], today: string, days = 30): CalendarItem[] { - return calendarItems(entries, todos, { from: today, to: addDays(today, days), overdue: true }); +export function upcoming(entries: Entry[], todos: (Todo & { title?: string })[], today: string, days = 30, now?: Date): CalendarItem[] { + return calendarItems(entries, todos, { from: today, to: addDays(today, days), overdue: true, today, now }); } diff --git a/src/core/contacts.ts b/src/core/contacts.ts index 6d18b82..837965d 100644 --- a/src/core/contacts.ts +++ b/src/core/contacts.ts @@ -28,11 +28,14 @@ import type { Entry } from "./entry.ts"; import { resolveLink } from "./entry.ts"; import { linksTo, markdownLinkTarget } from "./relations.ts"; import { metaValue, yearlyDate } from "./calendar.ts"; +import { recordsIn } from "./fields.ts"; import { escapeText, foldLine } from "./ical.ts"; import { isSealedValue } from "./sealed.ts"; import { slugify } from "./util.ts"; export const PEOPLE_COLLECTION = "people"; +/** Where organization records are kept by default; organizations.ts reads them. */ +export const ORGANIZATIONS_COLLECTION = "organizations"; /** The vCard property each front matter key stands for, in the order a card is written. */ const PROPERTIES: [key: string, property: string][] = [ @@ -113,6 +116,28 @@ export function orgReference(from: Entry, value: string): OrgReference { return cut < 0 ? { name: s, units: "", path: null } : { name: s.slice(0, cut).trim(), units: s.slice(cut + 1).trim(), path: null }; } +const nameKey = (s: string) => s.trim().replace(/\s+/g, " ").toLowerCase(); + +/** The names an organization goes by: its title, `legalName` and every `alternateName`, ignoring case and spacing. */ +export function organizationNames(r: Entry): Set { + return new Set([r.title, ...values(metaValue(r.meta, "legalName")), ...values(metaValue(r.meta, "alternateName"))].map(nameKey).filter(Boolean)); +} + +/** Whether an organization goes by a name, as an `org` that names it rather than linking to it writes it. */ +export const goesBy = (names: Set, name: string): boolean => names.has(nameKey(name)); + +/** + * The organization record an `org` refers to: the one it links to, or, when + * it gives a name, the one organization of `orgs` that goes by that name. + * Null when it names none of them, or more than one. + */ +export function orgPathOf(from: Entry, value: string, orgs: Entry[]): string | null { + const ref = orgReference(from, value); + if (ref.path || !ref.name) return ref.path; + const named = orgs.filter((o) => o.path !== from.path && goesBy(organizationNames(o), ref.name)); + return named.length === 1 ? named[0].path : null; +} + /** An `org` value as vCard writes it: a link becomes its text, so "[Acme](…);Research" is "Acme;Research". */ const orgPlain = (from: Entry, value: string): string => { const o = orgReference(from, value); @@ -150,8 +175,8 @@ export interface Contact { const isEvent = (e: Entry) => e.path.toLowerCase().startsWith(".gitroll/events/"); -/** One record, read as a person. `events` are searched for links to it. */ -export function contactOf(r: Entry, events: Entry[]): Contact { +/** One record, read as a person. `events` are searched for links to it, and `orgs` for the organization its `org` names. */ +export function contactOf(r: Entry, events: Entry[], orgs: Entry[] = []): Contact { // A link in an event's front matter (`with: "[Ada](../notes/people/ada.md)"`) counts as one in its text. const interactions = events .filter((e) => e.path !== r.path && linksTo(e, r.path)) @@ -165,7 +190,7 @@ export function contactOf(r: Entry, events: Entry[]): Contact { emails: values(metaValue(r.meta, "email")), tels: values(metaValue(r.meta, "tel")), org: org ? partsText(orgPlain(r, org)) || null : null, - orgPath: org ? orgReference(r, org).path : null, + orgPath: org ? orgPathOf(r, org, orgs) : null, jobTitle: first(r.meta, "jobTitle"), nickname: values(metaValue(r.meta, "nickname")).join(", ") || null, addresses: values(metaValue(r.meta, "adr")).map(partsText).filter(Boolean), @@ -183,12 +208,17 @@ export interface Contacts { contacts: Contact[]; } -/** The contacts view: the records given, as people, by name. `docs` is every document, so links from events are found. */ +/** + * The contacts view: the records given, as people, by name. `docs` is every + * document, so links from events are found, and an `org` that names an + * organization is matched against the organizations collection's records. + */ export function contacts(records: Entry[], docs: Entry[], collection = PEOPLE_COLLECTION): Contacts { const events = docs.filter(isEvent); + const orgs = recordsIn(docs, ORGANIZATIONS_COLLECTION); return { collection, - contacts: records.map((r) => contactOf(r, events)).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.path.localeCompare(b.path)), + contacts: records.map((r) => contactOf(r, events, orgs)).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.path.localeCompare(b.path)), }; } diff --git a/src/core/entry.ts b/src/core/entry.ts index a95d34e..735eb9e 100644 --- a/src/core/entry.ts +++ b/src/core/entry.ts @@ -8,7 +8,7 @@ // writes by hand is kept exactly as written; GitRoll never rewrites a file it // was not asked to change. -import { parseDocument } from "yaml"; +import { isCollection, parseDocument, visit } from "yaml"; import type { Document } from "yaml"; import { isRealTimestamp, slugify } from "./util.ts"; @@ -240,6 +240,32 @@ const unique = (xs: T[]): T[] => [...new Set(xs)]; // class already stopped at `)`, and `relativeLink` percent-encodes both. const LINK = /(!)?\[([^\][]*)\]\(\s*]+)>?\s*(?:"[^"]*"\s*)?\)/g; +/** A run of text as a reader shows it, and the target as written when it is a Markdown link's text. */ +export interface TextPart { + text: string; + target?: string; +} + +/** + * A field's value or a to-do's words split into plain text and Markdown + * links, so a reader shows each link as its text (and links it, where it + * can) rather than as `[Maria](../people/maria.md)`. + */ +export function textParts(s: string): TextPart[] { + const out: TextPart[] = []; + let at = 0; + for (const m of s.matchAll(LINK)) { + if (m.index > at) out.push({ text: s.slice(at, m.index) }); + out.push({ text: m[2], target: m[3] }); + at = m.index + m[0].length; + } + if (at < s.length) out.push({ text: s.slice(at) }); + return out; +} + +/** Text with each Markdown link as its text alone: "Call [Maria](maria.md)" is "Call Maria". */ +export const linksAsText = (s: string): string => textParts(s).map((p) => p.text).join(""); + /** The directory part of a repository-relative path ("" at the root). */ export const dirName = (p: string): string => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : ""); @@ -402,6 +428,22 @@ export function updateEntrySource(source: string, changes: MetaChanges = EMPTY, return yaml ? `---\n${yaml}\n---\n\n${nextBody}` : nextBody; } +/** + * A front matter document as text, written the way the file already writes + * it: no line wrapping, and flow lists padded (`[ bike ]`) or not (`[bike]`) + * as its existing ones are, so an edit changes only the keys it touched. A + * document without a flow list yet is padded, as the YAML library writes it. + */ +export function yamlText(doc: Document, source: string): string { + let padded = true; + visit(parseDocument(source), (_, node) => { + if (!isCollection(node) || !node.flow || !node.items.length || !node.range) return; + padded = /\s/.test(source[node.range[0] + 1] ?? " "); + return visit.BREAK; + }); + return doc.toString({ lineWidth: 0, flowCollectionPadding: padded }); +} + /** Edits the YAML document in place, so comments, key order and style survive. Returns "" when nothing is left. */ function editFrontMatter(frontMatter: string | null, changes: MetaChanges): string { const doc = parseDocument(frontMatter ?? ""); @@ -410,7 +452,7 @@ function editFrontMatter(frontMatter: string | null, changes: MetaChanges): stri (doc as { contents: unknown }).contents = doc.createNode({}); } applyMeta(doc, changes); - const text = doc.toString({ lineWidth: 0 }).trim(); + const text = yamlText(doc, frontMatter ?? "").trim(); return text === "{}" ? "" : text; } diff --git a/src/core/fields.ts b/src/core/fields.ts index 84a1340..f616419 100644 --- a/src/core/fields.ts +++ b/src/core/fields.ts @@ -15,7 +15,7 @@ import { isMap, isScalar, parseDocument } from "yaml"; import type { Document, Node, Scalar, YAMLMap } from "yaml"; import type { Entry } from "./entry.ts"; -import { FormatError, splitFrontMatter } from "./entry.ts"; +import { FormatError, splitFrontMatter, yamlText } from "./entry.ts"; import { NOTES_DIR } from "./layout.ts"; import { UserError, isRealTimestamp } from "./util.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. */ @@ -385,7 +400,7 @@ export function setFields(source: string, set: Map | [string } map.set(at ?? key, node); } - const yaml = map.items.length ? doc.toString({ lineWidth: 0 }).trimEnd() : ""; + const yaml = map.items.length ? yamlText(doc, frontMatter ?? "").trimEnd() : ""; if (!yaml) return frontMatter === null ? source : body.replace(/^\r?\n/, ""); return frontMatter === null ? `---\n${yaml}\n---\n\n${source.replace(/^/, "")}` : `---\n${yaml}\n---\n${body}`; } diff --git a/src/core/ical.ts b/src/core/ical.ts index c8d1824..9cd8278 100644 --- a/src/core/ical.ts +++ b/src/core/ical.ts @@ -16,7 +16,7 @@ import type { Entry } from "./entry.ts"; import type { Todo } from "./todos.ts"; -import { DUE_FIELDS, RRuleError, YEARLY_FIELDS, addDays, dateField, formatRRule, linkText, metaValue, parseRRule, recurrenceRule, taskDates, yearlyDate, yearlyOn } from "./calendar.ts"; +import { DUE_FIELDS, RRuleError, YEARLY_FIELDS, addDays, dateField, eventAhead, formatRRule, linkText, metaValue, parseRRule, recurrenceRule, taskDates, yearlyDate, yearlyOn } from "./calendar.ts"; import { formatDuration, instant, isLocalTime, readRemind, reminderTitle, remindValues, secondsDuration, taskReminder, wallSeconds } from "./reminders.ts"; import { slugify } from "./util.ts"; @@ -125,7 +125,7 @@ export interface ICalendarOptions { name?: string; /** DTSTAMP: when this file was made. */ now?: Date; - /** Events dated on or after this day (YYYY-MM-DD) are included when they have no `start`. */ + /** Events still to come on this day (YYYY-MM-DD) are included when they have no `start`: see eventAhead. All of them when omitted. */ today?: string; /** The IANA time zone a reminder's local time is read in when it must be written as UTC; this computer's when omitted. */ timeZone?: string; @@ -133,7 +133,7 @@ export interface ICalendarOptions { /** * The Roll's calendar as an RFC 5545 VCALENDAR: an event for each `start` - * (with its RRULE), for each event dated today or later, and for each due-ish + * (with its RRULE), for each event still to come, and for each due-ish * date field, and a to-do (VTODO) for each open to-do with a 📅 date or a ⏰ * reminder; each reminder is a VALARM in its event or to-do. */ @@ -151,7 +151,7 @@ export function toICalendar(entries: Entry[], todos: (Todo & { title?: string }) }; for (const e of entries) { const start = dateField(metaValue(e.meta, "start")); - const dated = !start && e.path.toLowerCase().startsWith(".gitroll/events/") && e.date && (!opts.today || e.date.slice(0, 10) >= opts.today) ? e.date : null; + const dated = !start && e.path.toLowerCase().startsWith(".gitroll/events/") && e.date && (!opts.today || eventAhead(e.date, opts.today, opts.now)) ? e.date : null; const begin = start ?? dated; if (begin) { lines.push("BEGIN:VEVENT", uid(e.path, "start"), stamp, dateProperty("DTSTART", begin)); 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/layout.ts b/src/core/layout.ts index 5d4f778..d67d0ec 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -481,6 +481,17 @@ export function requireDate(date: string): string { const bodyOf = (source: string) => splitFrontMatter(source).body.replace(/^\s*\n/, "").trimEnd(); +/** + * New text for a document that keeps its `# Title` heading, as `edit --text` + * writes it, so replacing the words can't rename a contact by accident. Text + * that starts with a `# ` heading of its own replaces the old one. + */ +export function keepHeading(source: string, text: string): string { + if (/^\s*# /.test(text)) return text; + const first = bodyOf(source).split("\n").find((l) => l.trim()) ?? ""; + return /^# \S/.test(first) ? `${first.trimEnd()}\n\n${text.trim()}` : text; +} + /** * Applies changes to an event file's text. Untouched metadata, comments, * handwritten formatting and links all survive: only the keys that changed are diff --git a/src/core/ledger.ts b/src/core/ledger.ts index 6a648b6..5892529 100644 --- a/src/core/ledger.ts +++ b/src/core/ledger.ts @@ -3,7 +3,10 @@ // 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 that links to its record is its purchase (dated on its +// `purchaseDate`, or for exactly its price when it has none): that event is +// the transaction, 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 +14,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 +71,41 @@ 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 events with an `amount` that link to each document, in their text or their front matter. */ +function eventsWithAmounts(docs: Entry[]): Map { + const out = new Map(); + for (const e of docs) { + if (!e.amount || !e.path.toLowerCase().startsWith(".gitroll/events/")) continue; + for (const to of documentLinks(e)) out.set(to, [...(out.get(to) ?? []), e]); + } + return out; +} + +/** + * Whether an event that links to a record is its purchase: dated on the + * record's `purchaseDate`, or, when it has none, for exactly its price in the + * same currency. Any other event that links to it (a service, a repair) isn't. + */ +export function isPurchase(event: Entry, record: Entry, price: Amount): boolean { + if (!event.amount) return false; + const bought = day(metaValue(record.meta, "purchaseDate")); + if (bought) return day(event.date) === bought; + return event.amount.currency === price.currency && Math.round((event.amount.value - price.value) * 1e6) === 0; +} + +/** + * 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 whose + * purchase is an event that links to it (see isPurchase): 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 linking = eventsWithAmounts(all); for (const e of docs) { - const price = e.amount ? null : priceOf(e.meta); + const own = e.amount ? null : priceOf(e.meta); + const price = own && !(linking.get(e.path) ?? []).some((ev) => isPurchase(ev, e, own)) ? own : null; 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 +155,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/organizations.ts b/src/core/organizations.ts index c63d619..7a23fcf 100644 --- a/src/core/organizations.ts +++ b/src/core/organizations.ts @@ -10,16 +10,17 @@ // // It is not a new kind of file: `.gitroll/notes/organizations/` is a // collection like any other. Its people are the contacts whose `org` names it -// or links to it, so nothing is written twice, and events that link to it are -// its history, read as backlinks, as they are for a person. +// or links to it, so nothing is written twice; the things it supplied are the +// records whose `vendor` links to it; and events that link to it are its +// history, read as backlinks, as they are for a person. import type { Entry } from "./entry.ts"; import { linkText, metaValue } from "./calendar.ts"; -import { orgReference, values } from "./contacts.ts"; +import { ORGANIZATIONS_COLLECTION, goesBy, organizationNames, orgReference, values } from "./contacts.ts"; import type { Interaction } from "./contacts.ts"; import { fieldLink, frontMatterLinks } from "./relations.ts"; -export const ORGANIZATIONS_COLLECTION = "organizations"; +export { ORGANIZATIONS_COLLECTION }; /** The schema.org keys an organization record may use, in the order a card shows them. */ export const ORGANIZATION_FIELDS = ["legalName", "alternateName", "url", "email", "telephone", "address", "foundingDate", "sameAs", "parentOrganization", "location"]; @@ -112,6 +113,8 @@ export interface Organization { location: Ref | null; /** People whose `org` links to this record or names it, by name. */ members: Member[]; + /** Things (any note, such as an inventory record) whose `vendor` links to it, by title. */ + supplied: { path: string; title: string }[]; /** Events that link to it, newest first. */ interactions: Interaction[]; /** The date of the newest of those. */ @@ -124,14 +127,8 @@ export interface Organizations { } const byName = (a: T, b: T) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.path.localeCompare(b.path); -const key = (s: string) => s.trim().replace(/\s+/g, " ").toLowerCase(); const first = (meta: Record, k: string): string | null => values(metaValue(meta, k))[0] ?? null; -/** The names an organization goes by: its title, `legalName` and every `alternateName`. */ -function namesOf(r: Entry): Set { - return new Set([r.title, ...values(metaValue(r.meta, "legalName")), ...values(metaValue(r.meta, "alternateName"))].map(key).filter(Boolean)); -} - /** * The organizations view: the records given, by name, each with its people * and its history. `docs` is every document, so people and events are found. @@ -146,15 +143,19 @@ export function organizations(records: Entry[], docs: Entry[], collection = ORGA return org ? [{ doc: d, ref: orgReference(d, org) }] : []; }); const orgs = records.map((r): Organization => { - const names = namesOf(r); + const names = organizationNames(r); const members = affiliations - .filter(({ doc, ref }) => doc.path !== r.path && (ref.path ? ref.path === r.path : names.has(key(ref.name)))) + .filter(({ doc, ref }) => doc.path !== r.path && (ref.path ? ref.path === r.path : goesBy(names, ref.name))) .map(({ doc, ref }) => ({ path: doc.path, name: first(doc.meta, "fn") ?? doc.title, jobTitle: first(doc.meta, "jobTitle"), units: ref.units ? ref.units.split(";").map((p) => p.trim()).filter(Boolean).join(", ") || null : null })) .sort(byName); const subOrganizations = (into.get(r.path) ?? []) .filter((d) => d.path !== r.path && refOf(d, metaValue(d.meta, "parentOrganization"))?.path === r.path) .map((d) => ({ path: d.path, name: d.title })) .sort(byName); + const supplied = (into.get(r.path) ?? []) + .filter((d) => isNote(d) && d.path !== r.path && refOf(d, metaValue(d.meta, "vendor"))?.path === r.path) + .map((d) => ({ path: d.path, title: d.title })) + .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) || a.path.localeCompare(b.path)); const interactions = eventsLinking(r.path, into); const dated = interactions.find((i) => i.date); const parent = refOf(r, metaValue(r.meta, "parentOrganization")); @@ -176,6 +177,7 @@ export function organizations(records: Entry[], docs: Entry[], collection = ORGA subOrganizations, location, members, + supplied, interactions, lastContacted: dated?.date ? dated.date.slice(0, 10) : null, }; 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/relations.ts b/src/core/relations.ts index 9ca03c2..ad9da42 100644 --- a/src/core/relations.ts +++ b/src/core/relations.ts @@ -54,10 +54,17 @@ export function markdownLinkTarget(v: string): string | null { return target || null; } -/** Where a front matter value that is a Markdown link points, resolved against its document; null when it isn't one. */ +/** A link followed by `;` and more parts, as vCard's structured `org` writes it: `[Acme](acme.md);Research`. */ +const LINK_THEN_PARTS = /^\s*(\[[^\]\n]*\]\([^()\s]*\))\s*;/; + +/** + * Where a front matter value that is a Markdown link points, resolved against + * its document; null when it isn't one. A link followed by `;` and more parts + * (`org: "[Acme](acme.md);Research"`) links to what its link part names. + */ export function fieldLink(from: Entry, v: unknown): string | null { if (typeof v !== "string") return null; - const target = markdownLinkTarget(v); + const target = markdownLinkTarget(LINK_THEN_PARTS.exec(v)?.[1] ?? v); return target ? resolveLink(from.path, target) : null; } diff --git a/src/core/reminders.ts b/src/core/reminders.ts index cbc9f2f..07cc250 100644 --- a/src/core/reminders.ts +++ b/src/core/reminders.ts @@ -366,7 +366,7 @@ export function reminders(entries: Entry[], todos: (Todo & { title?: string })[] * `today` should be the date at `now` in `timeZone` (isoDateIn). */ export function upcomingWithReminders(entries: Entry[], todos: (Todo & { title?: string })[], today: string, days = 30, now = new Date(), timeZone?: string): CalendarItem[] { - const items = upcoming(entries, todos, today, days); + const items = upcoming(entries, todos, today, days, now); const extra: CalendarItem[] = reminders(entries, todos, { now, to: addDays(today, days), timeZone }) .filter((r) => !r.problem) .map((r) => ({ diff --git a/src/core/sealed.ts b/src/core/sealed.ts index f447322..a705b0e 100644 --- a/src/core/sealed.ts +++ b/src/core/sealed.ts @@ -21,7 +21,7 @@ import { isMap, isScalar, parse, parseDocument, stringify } from "yaml"; import type { Node, Scalar, YAMLMap, YAMLSeq } from "yaml"; -import { FormatError, splitFrontMatter } from "./entry.ts"; +import { FormatError, splitFrontMatter, yamlText } from "./entry.ts"; import { ARMOR_BEGIN, ARMOR_END, isRecipient } from "./age/format.ts"; export const SEALED_INFO = "sealed"; @@ -202,7 +202,7 @@ function frontMatterMap(source: string) { if (!isMap(doc.contents)) throw new FormatError("front matter must be a mapping"); const map = doc.contents as YAMLMap; const key = (name: string) => map.items.map((p) => p.key).find((n): n is Scalar => isScalar(n) && String(n.value) === name); - const write = () => `---\n${doc.toString({ lineWidth: 0 }).trimEnd()}\n---\n${body}`; + const write = () => `---\n${yamlText(doc, frontMatter).trimEnd()}\n---\n${body}`; return { doc, map, key, write }; } 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/agent-guide.ts b/src/node/agent-guide.ts index 921ef28..c78d988 100644 --- a/src/node/agent-guide.ts +++ b/src/node/agent-guide.ts @@ -1,6 +1,6 @@ /** Packaged with the CLI so agents can discover the supported workflow offline. */ export const AGENT_GUIDE = { - version: 9, + version: 10, instructions: [ "GitRoll agent guide. Discover this guide with gitroll help agent --json. Run gitroll schema for the complete command catalog, or gitroll schema for arguments, accepted options, side effects and output contracts. gitroll --help also explains a command.", "Pass arguments as an argv array, without a shell, when possible. Select the intended Roll explicitly with -C or --roll . Use --json for the commands below. Use -- to separate positional text that begins with a dash from options.", @@ -19,7 +19,7 @@ export const AGENT_GUIDE = { "Dates and repeats use standards: start, end, location and rrule (an RFC 5545 RRULE) on events and notes; '📅 YYYY-MM-DD' and '🔁 every ' on to-do lines (Obsidian Tasks); a reminder is '⏰ YYYY-MM-DD HH:MM' on a to-do line (Obsidian Reminder, local time) or remind: on an event or note (an RFC 5545 duration from start such as -PT1H, or an ISO 8601 date-time; a list for several). Things use schema.org names (brand, model, serialNumber, price, priceCurrency, warranty, location, quantity, reorderAt). People, in notes/people/, use vCard's names lower-cased (email, tel, adr, org, bday, anniversary, url, nickname, categories, note; the job title is jobTitle and the record's title is the name); bday and anniversary are 1815-12-10, or --1210 without a year, and repeat yearly on the calendar. Organizations, in notes/organizations/, use schema.org's Organization names (url, email, telephone, address, foundingDate, sameAs, legalName, alternateName, and parentOrganization as a link); a person belongs to one when their org names it or is a link to it, e.g. org: '[Acme](../organizations/acme.md)'. Places, in notes/places/, use schema.org's Place names (address, telephone, url, latitude and longitude in decimal degrees) and nest with a within: link to another place. Write these with set or add rather than inventing keys. Restock to-dos from todos with derived: true are not in any file; change quantity instead of ticking them off.", "Pins and issues are front matter, not new kinds of file. pinned: true pins an event or note: recent and find (without --sort) list pinned entries first, and find is:pinned lists them; pin and unpin set or remove that one key and return {entry, notices, changed} like set, honouring --expect. issue: open (or true) marks an event or note as an issue, open until resolved: is set on it or a later event's resolves: is a Markdown link to it. issues --json lists open ones (--all adds resolved) with age, activity and resolvedBy; close --note logs an event that resolves it and leaves the issue's file alone, and writes nothing (changed: false) when it is already resolved, so a retry is safe. close is not resolve, which settles sync conflicts.", "Files: everything under .gitroll/files/ is a file in the Roll. files --json lists each with size, linkedFrom and unfiled (nothing links to it). attach copies exactly the one file on this computer that path names; only attach a file the user asked to put in the Roll. A file's fields live in its sidecar, files/.md (Dublin Core: title, creator, date, subject, description; plus any key): set files/ key=value writes it, and find is:file searches sidecars. A file larger than part_size is kept as numbered parts (name.ext.001, .002, ...) with parts, size and sha256 in its sidecar; links still name name.ext, and reassemble --out joins and verifies it.", - "Some content may be sealed (encrypted with age). Without a key, show and find return a sealed front matter field as {sealed: true} and list every sealed part in sealed: [{sealed: true, kind, field|lines}]; the body keeps the ciphertext block, so keep it as it is when you edit. show --unsealed opens sealed parts for display only, and only when this process has a key. Never unseal, and never copy sealed text anywhere, unless the user asked for exactly that; unseal requires --yes and commits plain text into history. reseal (--yes, or --dry-run to list what would change) seals sealed content again to the Roll's current recipients; run it only when the user asked, typically after recipients remove, and tell them the old ciphertext stays readable to a removed key in Git history. Making keys (gitroll key) is the person's to do and is not offered over MCP.", + "Some content may be sealed (encrypted with age). Without a key, show, find and every write's returned entry give a sealed front matter field as {sealed: true} and list every sealed part in sealed: [{sealed: true, kind, field|lines}]; the body keeps the ciphertext block, so keep it as it is when you edit. show --unsealed opens sealed parts for display only, and only when this process has a key. Never unseal, and never copy sealed text anywhere, unless the user asked for exactly that; unseal requires --yes and commits plain text into history. reseal (--yes, or --dry-run to list what would change) seals sealed content again to the Roll's current recipients; run it only when the user asked, typically after recipients remove, and tell them the old ciphertext stays readable to a removed key in Git history. Making keys (gitroll key) is the person's to do and is not offered over MCP.", "A Roll may contain .gitroll/AGENTS.md, a plain-language guide for agents that open the folder with only Git. gitroll agents-md prints it; --write (re)writes it.", "Example workflow: gitroll status -C /path/to/roll --json; gitroll find 'tag:incident' -C /path/to/roll --json; gitroll log 'Fixed checkout timeout' --tag incident -C /path/to/roll --json. These are separate invocations; quote text appropriately if using a shell.", ], @@ -32,7 +32,7 @@ export const AGENT_GUIDE = { { usage: "gitroll history -C --json", description: "Read an event's Git history", effect: "read" }, { usage: "gitroll related -C --json", description: "Read links and backlinks", effect: "read" }, { usage: "gitroll log --idempotency-key -C --json", description: "Create or replay an event; returns {entry, notices, replayed}", effect: "local write on first call" }, - { usage: "gitroll edit --text --expect -C --json", description: "Replace event text if its revision still matches; returns {entry, notices}", effect: "local write" }, + { usage: "gitroll edit --text --expect -C --json", description: "Replace event text if its revision still matches, keeping its # Title heading unless the new text starts with one; returns {entry, notices}", effect: "local write" }, { usage: "gitroll move -C --json", description: "Move an event to a path under .gitroll/events/ ending in .md; returns the updated entry", effect: "local write" }, { usage: "gitroll delete --yes -C --json", description: "Delete an event; returns {deleted: path}", effect: "local write; history retained" }, { usage: "gitroll deleted -C --json", description: "List deleted events with path, title, date, deletedAt and commit", effect: "read" }, diff --git a/src/node/cli-contract.ts b/src/node/cli-contract.ts index ccfe0e4..28eacab 100644 --- a/src/node/cli-contract.ts +++ b/src/node/cli-contract.ts @@ -49,7 +49,7 @@ export const COMMANDS: Record = { related: read("", "{links: string[], backlinks: string[], missing: string[]}", roll, 1), notes: read("[words...]", "Entry[] (notes, by title)", `${roll} ${paging}`, Infinity), note: write(" [text...]", "{entry, notices}", `${roll} editor project tag`, Infinity), - records: read("[collection] [query...]", "{name, path, records, description}[], or with a collection {collection, description, columns, total, records: {path, title, fields}[]}; --csv prints RFC 4180 CSV (with --json, {collection, csv})", `${roll} sort csv ${paging}`, Infinity), + records: read("[collection] [query...]", "{name, path, records, description}[], or with a collection {collection, description, columns, total, records: {path, title, fields}[]}; --csv prints RFC 4180 CSV, sealed fields as their ciphertext (with --json, {collection, csv, sealed: field[]})", `${roll} sort csv ${paging}`, Infinity), set: write("<file|query> [key=value...]", "{entry, notices, changed}; a path under files/ sets fields in that file's sidecar", `${roll} unset expect`, Infinity), files: { ...read("[query...]", "{path, title, size, parts, sidecar, revision, fields, linkedFrom, unfiled, missing}[]", `${roll} unfiled open limit offset`, Infinity), effect: "read; --open <file> opens one in this computer's app (a file in parts is joined into a temporary folder and checked first)" }, reassemble: { ...write("<file>", "{path, out, size, parts, sha256, verified}", `${roll} out`, 1), effect: "writes one new file at --out on this computer (never overwrites); joins a file kept in parts and checks its sha256" }, @@ -91,9 +91,9 @@ export const COMMANDS: Record<string, Command> = { ledger: read("[query...]", "{by, totals: {currency, total, count}[], groups: {key, totals, count}[], entries: {date, title, path, amount, field, projects, tags}[]}; --hledger returns a journal (with --json, {journal})", `${roll} by hledger`, Infinity), inventory: read("[query...]", "{collection, items, totals, groups?, warranties, restock}", `${roll} by collection`, Infinity), series: read("<field> [query...]", "{field, by, points: {date, value, currency, path, title, period?, readings?}[] by date, summaries: {currency, count, first, last, min, max, change, days, perDay, perMonth}[] (one per currency; plain numbers have currency null), skipped: {notNumeric, undated, items: {path, title, reason}[]}}; --by keeps the last reading in each period", `${roll} by`, Infinity), - organizations: read("[query...]", "{collection, organizations: {path, name, legalName, alternateNames, urls, emails, telephones, addresses, foundingDate, sameAs, parent: {name, path}|null, subOrganizations: {path, name}[], location: {name, path}|null, members: {path, name, jobTitle, units}[], interactions: {path, title, date}[], lastContacted}[]}; members are records whose org names or links the organization", `${roll} collection`, Infinity), + organizations: read("[query...]", "{collection, organizations: {path, name, legalName, alternateNames, urls, emails, telephones, addresses, foundingDate, sameAs, parent: {name, path}|null, subOrganizations: {path, name}[], location: {name, path}|null, members: {path, name, jobTitle, units}[], supplied: {path, title}[], interactions: {path, title, date}[], lastContacted}[]}; members are records whose org names or links the organization; supplied are notes whose vendor links it", `${roll} collection`, Infinity), places: read("[query...]", "{collection, places: {path, name, addresses, telephones, urls, coordinates: {latitude, longitude, uri}|null, parent, trail, depth, children, items, people, organizations, notes: {path, title}[], events: {path, title, date}[]}[]} in tree order (each place followed by those within it); uri is an RFC 5870 geo: URI", `${roll} collection`, Infinity), - contacts: read("[query...]", "{collection, contacts: {path, name, emails, tels, org, orgPath, jobTitle, nickname, addresses, urls, categories, bday, anniversary, interactions: {path, title, date}[], lastContacted}[]}; --vcf returns a vCard 4.0 file (with --json, {..., vcf})", `${roll} collection vcf`, Infinity), + contacts: read("[query...]", "{collection, contacts: {path, name, emails, tels, org, orgPath, jobTitle, nickname, addresses, urls, categories, bday, anniversary, interactions: {path, title, date}[], lastContacted}[]}; --vcf returns a vCard 4.0 file, sealed properties opened with a key on this computer and otherwise left out (with --json, {..., vcf, sealed: {path, field}[]} naming those left out)", `${roll} collection vcf`, Infinity), label: read("<record>", "{path, title, data, version, size, text} or with --svg {..., svg}", `${roll} svg`, 1), key: { ...write("[new]", "{path, keys: {recipient, name}[]} or with new {recipient, path, name, created}", "name", 1), effect: "read; new writes a secret key to your GitRoll settings folder, never into a repository (not offered over MCP)" }, recipients: { ...write("[add|remove <recipient>]", "{recipients: {recipient, label}[]}, plus added or removed", `${roll} name`, 2), effect: "read; add and remove change .gitroll/config.yaml and commit it" }, diff --git a/src/node/cli-files.ts b/src/node/cli-files.ts index 853d55d..a2b6a37 100644 --- a/src/node/cli-files.ts +++ b/src/node/cli-files.ts @@ -2,11 +2,15 @@ // reassemble <file> --out`, and `gitroll set` on a file's sidecar. Kept apart from cli.ts // so that file only dispatches. +import { linksAsText } from "../core/entry.ts"; import { NotFoundError } from "../core/util.ts"; import { assignments, resolveTarget } from "./cli-records.ts"; +import { masked } from "./cli-seal.ts"; +import { displayValue, maskFields } from "./sealing.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<string, string | boolean | string[] | undefined>; export interface Paint { @@ -26,10 +30,11 @@ 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)); + // A sealed field is `{sealed: true}`, as in every other list, never its ciphertext. + if (v.json) return console.log(JSON.stringify(page.map((f) => ({ ...f, fields: maskFields(f.fields) })), null, 2)); if (!page.length) { if (args.length || v.unfiled) return console.log(v.unfiled && !args.length ? "Every file is linked from an event or a note." : "No file matches that."); return console.log(`No files yet. Add one with: ${paint.bold("gitroll attach scan.pdf")} (or attach it to an event: gitroll log "..." scan.pdf)`); @@ -47,7 +52,7 @@ function printFile(f: FileInfo, paint: Paint): void { for (const from of f.linkedFrom) console.log(` ${paint.dim("linked from:")} ${shortPath(from)}`); for (const [key, value] of Object.entries(f.fields)) { if (["title", "parts", "size", "sha256"].includes(key)) continue; - console.log(` ${paint.dim(key)}: ${typeof value === "object" && !(value instanceof Date) ? JSON.stringify(value) : value instanceof Date ? value.toISOString().slice(0, 10) : value}`); + console.log(` ${paint.dim(key)}: ${displayValue(value) ?? (typeof value === "object" && !(value instanceof Date) ? JSON.stringify(value) : value instanceof Date ? value.toISOString().slice(0, 10) : linksAsText(String(value)))}`); } } @@ -95,10 +100,10 @@ export function fileForSet(roll: GitRoll, target: string): ReturnType<typeof fin /** `gitroll set <file> key=value` for a file under files/: its sidecar. */ export function setFileCommand(roll: GitRoll, file: NonNullable<ReturnType<typeof findFile>>, args: string[], v: Values, paint: Paint): void { const result = setFileFields(roll, file, assignments(args), (v.unset as string[] | undefined) ?? [], { expect: v.expect as string | undefined }); - if (v.json) return console.log(JSON.stringify(result, null, 2)); + if (v.json) return console.log(JSON.stringify(masked(result), null, 2)); console.log(result.changed ? paint.green("Saved.") : "Nothing to change: the fields already say that."); console.log(`${paint.bold(result.entry.title)} ${paint.dim(shortPath(result.entry.path))}`); - for (const [key, value] of Object.entries(result.entry.meta)) console.log(` ${paint.dim(key)}: ${typeof value === "object" ? JSON.stringify(value) : value}`); + for (const [key, value] of Object.entries(result.entry.meta)) console.log(` ${paint.dim(key)}: ${displayValue(value) ?? (typeof value === "object" ? JSON.stringify(value) : linksAsText(String(value)))}`); } /** Lines for `gitroll doctor`: how big the Roll is, and its largest files. */ diff --git a/src/node/cli-records.ts b/src/node/cli-records.ts index c63ac5c..b86e6a3 100644 --- a/src/node/cli-records.ts +++ b/src/node/cli-records.ts @@ -8,12 +8,14 @@ import { FIELD_NAME, collections, columnsOf, parseAssignment, parseSort, recordR import type { Collection, FieldInput } from "../core/fields.ts"; import type { LoadedEntry } from "../core/layout.ts"; import { NOTES_DIR } from "../core/layout.ts"; +import { linksAsText } from "../core/entry.ts"; import { SearchIndex } from "../core/search.ts"; import { SEALED_PLACEHOLDER } from "../core/sealed.ts"; 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<string, string | boolean | string[] | undefined>; @@ -49,7 +51,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); @@ -73,7 +75,8 @@ const cell = (v: unknown): string => { if (o.sealed === true) return SEALED_PLACEHOLDER; return "value" in o && "currency" in o ? `${o.value} ${o.currency}` : JSON.stringify(v); } - return String(v); + // A Markdown link reads as its text, here as everywhere a field is shown; --json keeps the value. + return linksAsText(String(v)); }; const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); @@ -93,7 +96,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-seal.ts b/src/node/cli-seal.ts index 9d80aa2..4d37ab7 100644 --- a/src/node/cli-seal.ts +++ b/src/node/cli-seal.ts @@ -11,6 +11,7 @@ import { listKeys, newKey, removeRecipient, + maskEntry, reseal, rollRecipients, sealDocument, @@ -131,12 +132,22 @@ export async function unsealCommand(roll: GitRoll, arg: string, v: Values, out: for (const n of result.notices) out.warn(n); } +/** + * A write's JSON with its entry as a read shows it: a sealed field is + * `{sealed: true}`, never its ciphertext, and `sealed` lists the sealed parts. + * The file itself is as it was written. + */ +export function masked<T extends { entry?: LoadedEntry | null }>(result: T): T { + return result?.entry ? { ...result, entry: maskEntry(result.entry) } : result; +} + /** * A save's JSON with a `seal` suggestion added when it turned up something * that looks like a secret: the exact command that would seal those lines. + * Its entry is masked, as every write's is. */ export function withSealHint<T extends { entry: LoadedEntry }>(roll: GitRoll, result: T): T & { seal?: { path: string; lines: string; command: string } } { - if (!result?.entry || !findSensitive(withoutSealed(result.entry.body)).length) return result; + if (!result?.entry || !findSensitive(withoutSealed(result.entry.body)).length) return masked(result); const seal = sealSuggestion(roll, result.entry.path); - return seal ? { ...result, seal } : result; + return seal ? { ...masked(result), seal } : masked(result); } diff --git a/src/node/cli-views.ts b/src/node/cli-views.ts index 9a57f30..63c5c80 100644 --- a/src/node/cli-views.ts +++ b/src/node/cli-views.ts @@ -6,18 +6,19 @@ import fs from "node:fs"; import { parseCsv, planCsvImport, recordsToCsv } from "../core/csv.ts"; import type { CsvPlan } from "../core/csv.ts"; -import { addDays, calendarItems } from "../core/calendar.ts"; +import { addDays, calendarItems, metaValue } from "../core/calendar.ts"; import type { CalendarItem } from "../core/calendar.ts"; +import { linksAsText } from "../core/entry.ts"; import { toICalendar } from "../core/ical.ts"; import { reminders, upcomingWithReminders } from "../core/reminders.ts"; import type { Reminder } from "../core/reminders.ts"; -import { PEOPLE_COLLECTION, contacts, planVcfImport, toVCard } from "../core/contacts.ts"; +import { CONTACT_FIELDS, PEOPLE_COLLECTION, contacts, planVcfImport, toVCard } from "../core/contacts.ts"; import type { Contacts, VcfPlan } from "../core/contacts.ts"; import { ORGANIZATIONS_COLLECTION, organizations } from "../core/organizations.ts"; 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"; @@ -27,18 +28,23 @@ import { encodeQr, qrToSvg, qrToText } from "../core/qr.ts"; import type { LoadedEntry } from "../core/layout.ts"; import { FIELD_NAME, collections, columnsOf, recordsIn } from "../core/fields.ts"; import { SearchIndex } from "../core/search.ts"; +import { isSealedValue } from "../core/sealed.ts"; import { formatReading, isSeriesBy, series, sparkline } from "../core/series.ts"; 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"; +import { openFields } from "./sealing.ts"; type Values = Record<string, string | boolean | string[] | undefined>; 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 +56,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<GitRoll["todos"]> } { + 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 { @@ -66,7 +83,7 @@ export function formatReminders(list: Reminder[], paint: { bold: Paint; dim: Pai const where = r.line ? `${shortPath(r.path)}:${r.line}` : shortPath(r.path); if (r.problem) return `${paint.red("not read".padEnd(16))} ${r.title}: ${r.problem} ${paint.dim(where)}`; const when = `${r.at.slice(0, 10)} ${r.at.slice(11, 16)}`; - return `${r.due ? paint.red(when.padEnd(16)) : paint.bold(when.padEnd(16))} ${r.title}${r.due ? paint.red(" (due now)") : ""}${r.remind ? paint.dim(` (remind: ${r.remind})`) : ""} ${paint.dim(where)}`; + return `${r.due ? paint.red(when.padEnd(16)) : paint.bold(when.padEnd(16))} ${linksAsText(r.title)}${r.due ? paint.red(" (due now)") : ""}${r.remind ? paint.dim(` (remind: ${r.remind})`) : ""} ${paint.dim(where)}`; }) .join("\n"); } @@ -76,9 +93,9 @@ 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 ahead = calendarItems(docs.filter((d) => d.path.startsWith(".gitroll/events/") && !hasStart(d)), [], { from: today }); + 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, 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); return [...starts, ...ahead, ...yearless].filter((i) => i.kind !== "occurrence").sort((a, b) => a.date.slice(0, 10).localeCompare(b.date.slice(0, 10)) || a.title.localeCompare(b.title)); @@ -88,7 +105,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<CalendarItem["kind"], string> = { event: "", occurrence: "repeats", todo: "to-do", field: "", reminder: "reminder" }; @@ -97,7 +115,7 @@ export function formatUpcoming(items: CalendarItem[], paint: { bold: Paint; dim: const lines: string[] = []; for (const i of items) { const when = i.date.length > 10 ? `${i.date.slice(0, 10)} ${i.date.slice(11, 16)}` : i.date; - const what = i.kind === "field" ? `${i.title}: ${i.field}` : i.title; + const what = i.kind === "field" ? `${i.title}: ${i.field}` : linksAsText(i.title); const notes = [KIND[i.kind], i.due ? "due now" : "", i.years !== undefined ? `${i.years} ${i.years === 1 ? "year" : "years"}` : "", i.location ? `at ${i.location}` : "", i.recurrence ? `🔁 ${i.recurrence}` : "", i.problem ? `rrule not read: ${i.problem}` : ""].filter(Boolean).join(", "); const where = i.line ? `${shortPath(i.path)}:${i.line}` : shortPath(i.path); lines.push(`${i.overdue || i.due ? paint.red(when.padEnd(16)) : paint.bold(when.padEnd(16))} ${what}${notes ? paint.dim(` (${notes})`) : ""} ${paint.dim(where)}`); @@ -111,7 +129,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 +204,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(); @@ -238,14 +257,27 @@ const collectionOption = (values: Values, fallback: string): string => { return folder; }; -/** `gitroll contacts [query]`: the people in notes/people/ (or --collection), with when each was last in an event. */ -export function contactsView(roll: GitRoll, query: string, values: Values): Contacts & { vcf?: string } { +/** + * `gitroll contacts [query]`: the people in notes/people/ (or --collection), + * with when each was last in an event. With --vcf, a vCard file of them: a + * sealed property is written opened when a key on this computer opens it (it + * is the key holder's own address book going out), and otherwise left out and + * listed in `sealed`. + */ +export async function contactsView(roll: GitRoll, query: string, values: Values): Promise<Contacts & { vcf?: string; sealed?: { path: string; field: string }[] }> { const name = collectionOption(values, PEOPLE_COLLECTION); const records = filtered(recordsIn(roll.notes(), name), query); const view = contacts(records, roll.documents(), name); if (!values.vcf) return view; const byPath = new Map(records.map((r) => [r.path, r])); - return { ...view, vcf: toVCard(view.contacts.map((c) => byPath.get(c.path)!)) }; + const cards: LoadedEntry[] = []; + const sealed: { path: string; field: string }[] = []; + for (const c of view.contacts) { + const opened = await openFields(byPath.get(c.path)!, CONTACT_FIELDS); + cards.push(opened.entry); + for (const field of opened.closed) sealed.push({ path: c.path, field }); + } + return { ...view, vcf: toVCard(cards), sealed }; } export function formatContacts(view: Contacts, paint: { bold: Paint; dim: Paint } = { bold: plain, dim: plain }): string { @@ -273,8 +305,9 @@ export function formatOrganizations(view: Organizations, paint: { bold: Paint; d .map((o) => { const reach = [...o.urls.slice(0, 1), ...o.telephones.slice(0, 1), o.parent ? `part of ${o.parent.name}` : ""].filter(Boolean).join(" "); const people = o.members.length ? `${o.members.length} ${o.members.length === 1 ? "person" : "people"}: ${o.members.map((m) => m.name).join(", ")}` : ""; + const supplied = o.supplied.length ? `supplied ${o.supplied.length === 1 ? "1 thing" : `${o.supplied.length} things`}: ${o.supplied.map((s) => s.title).join(", ")}` : ""; const last = o.lastContacted ? `last contacted ${o.lastContacted}` : ""; - return `${paint.bold(o.name)}${reach ? ` ${reach}` : ""}${people ? ` ${people}` : ""}${last ? paint.dim(` ${last}`) : ""}`; + return `${paint.bold(o.name)}${reach ? ` ${reach}` : ""}${people ? ` ${people}` : ""}${supplied ? ` ${supplied}` : ""}${last ? paint.dim(` ${last}`) : ""}`; }) .join("\n"); } @@ -282,7 +315,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 { @@ -351,14 +385,20 @@ export function importVcf(roll: GitRoll, file: string, values: Values, dryRun: b // ── CSV ──────────────────────────────────────────────────────────────────── /** `gitroll records <collection> --csv`: RFC 4180, a title column then one per field. */ -export function recordsCsv(roll: GitRoll, name: string, query: string, values: Values): string { +/** + * `gitroll records <collection> --csv`. A sealed field goes out as its + * ciphertext, which `import csv` brings back sealed; `sealed` names the + * columns that have any, so the command can say so. + */ +export function recordsCsv(roll: GitRoll, name: string, query: string, values: Values): { csv: string; sealed: string[] } { const notes = roll.notes(); const want = name.split("/").filter(Boolean).join("/").toLowerCase(); if (!collections(notes).some((c) => c.name.toLowerCase() === want)) throw new NotFoundError(`There's no collection called "${name}".`); const records = recordsIn(notes, name); const rows = sortedBy(filtered(records, query), values.sort as string | undefined); const columns = values.fields === undefined ? columnsOf(records) : String(values.fields).split(",").map((s) => s.trim()).filter(Boolean); - return recordsToCsv(rows, columns); + const sealed = columns.filter((c) => rows.some((r) => isSealedValue(metaValue(r.meta, c)))); + return { csv: recordsToCsv(rows, columns), sealed }; } export interface CsvImportResult { diff --git a/src/node/cli.ts b/src/node/cli.ts index c64b50b..1ae8578 100755 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -7,12 +7,12 @@ 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"; import { signingStatus } from "./signing.ts"; -import { keyCommand, recipientsCommand, resealCommand, sealCommand, unsealCommand, withSealHint } from "./cli-seal.ts"; +import { keyCommand, masked, recipientsCommand, resealCommand, sealCommand, unsealCommand, withSealHint } from "./cli-seal.ts"; import { displayBody, displayValue, maskEntry, presentEntry } from "./sealing.ts"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -23,15 +23,15 @@ import { parseArgs } from "node:util"; import { planIngest, withDefaults } from "../core/adapter.ts"; import { ADAPTERS, getAdapter } from "../core/adapters/index.ts"; import type { Amount } from "../core/entry.ts"; -import { FormatError, parseEntry } from "../core/entry.ts"; +import { FormatError, linksAsText, parseEntry } from "../core/entry.ts"; import type { EntryChanges, LoadedEntry } from "../core/layout.ts"; -import { TEMPLATES_DIR, errorsOnly, findEntry, isNote } from "../core/layout.ts"; +import { TEMPLATES_DIR, errorsOnly, findEntry, isNote, keepHeading } 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"; +import { linksTo, related } from "../core/relations.ts"; import { BUILT_IN_TEMPLATES, pickTemplate, renderTemplate } from "../core/templates.ts"; import { NotFoundError, UserError, basename, extname, isoDate, mimeFor, parseAmount, summarize } from "../core/util.ts"; import { gh, ghSignedIn, githubVisibility, hasGh, parseGitHubRemote } from "./github.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 <name>] [--all] Keep a search to reuse as @name, or search every Roll you have + [--save <name>] [--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 <note>] 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 <words | file:line> 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<void> { 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}`); @@ -616,7 +617,7 @@ async function main(argv: string[]): Promise<void> { printEntry(shown, names(roll)); for (const [key, value] of Object.entries(shown.meta)) { if (["projects", "tags", "amount", "currency", "date", "title", "source"].includes(key)) continue; - console.log(` ${dim(key)}: ${displayValue(value) ?? (typeof value === "object" ? JSON.stringify(value) : value)}`); + console.log(` ${dim(key)}: ${displayValue(value) ?? (typeof value === "object" ? JSON.stringify(value) : linksAsText(String(value)))}`); } if (v.unsealed && shown.sealed?.some((p) => p.text === undefined)) console.log(yellow(" Some sealed parts stay sealed: no key on this computer opens them.")); for (const a of e.attachments) { @@ -638,7 +639,8 @@ async function main(argv: string[]): Promise<void> { 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"}`)); @@ -671,7 +673,10 @@ async function main(argv: string[]): Promise<void> { } if (v.at !== undefined) changes.date = v.at; if (v.amount !== undefined) changes.amount = v.amount === "none" ? null : amountArg(v.amount); - const { entry, notices } = roll.saveChanges(need(id, 'gitroll edit <file> --text "..."'), changes, files, { expect: v.editor ? undefined : v.expect }); + const target = need(id, 'gitroll edit <file> --text "..."'); + // New words keep the title: only text that starts with a `# ` heading renames it. + if (changes.text !== undefined) changes.text = keepHeading(roll.entrySource(target), changes.text); + const { entry, notices } = roll.saveChanges(target, changes, files, { expect: v.editor ? undefined : v.expect }); if (v.json) return console.log(JSON.stringify(withSealHint(roll, { entry, notices }), null, 2)); console.log(green(roll.config().autoCommit ? "Saved. The earlier version is kept in history." : "Saved.")); printEntry(entry, names(roll)); @@ -739,7 +744,7 @@ async function main(argv: string[]): Promise<void> { case "recover": { const roll = openRoll(); const entry = roll.restoreDeleted(need(args[0], "gitroll undelete <file> (see: gitroll deleted)")); - if (v.json) return console.log(JSON.stringify({ entry }, null, 2)); + if (v.json) return console.log(JSON.stringify(masked({ entry }), null, 2)); console.log(green("Back in the Roll, exactly as it was.") + dim(" The deletion and the recovery are both in history.")); return printEntry(entry, names(roll)); } @@ -750,14 +755,14 @@ async function main(argv: string[]): Promise<void> { // here at all — the event itself, back from the deleted list. if (!args[1] && !roll.entries().some((e) => findable(e, file))) { const entry = roll.restoreDeleted(file); - if (v.json) return console.log(JSON.stringify({ entry }, null, 2)); + if (v.json) return console.log(JSON.stringify(masked({ entry }), null, 2)); console.log(green("That event was deleted. It's back in the Roll, exactly as it was.")); return printEntry(entry, names(roll)); } const commit = args[1] ?? roll.previousVersion(file); if (!commit) throw new UserError("This event has only ever said one thing, so there's nothing earlier to put back."); const { entry, from, unchanged } = roll.restoreVersion(file, commit); - if (v.json) return console.log(JSON.stringify({ entry, from, unchanged }, null, 2)); + if (v.json) return console.log(JSON.stringify(masked({ entry, from, unchanged }), null, 2)); if (unchanged) return console.log(`That version of ${eventName(entry.path)} is already what's here. Nothing changed.`); console.log(green(`Put back the version from ${from}, as a new commit.`) + dim(" Every version in between is still in history.")); return printEntry(entry, names(roll)); @@ -766,7 +771,8 @@ async function main(argv: string[]): Promise<void> { const roll = openRoll(); const entries = roll.documents(); const e = findEntry(entries, need(args[0], "gitroll related <file>")); - 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 +795,7 @@ async function main(argv: string[]): Promise<void> { // ── 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) { @@ -836,9 +842,11 @@ async function main(argv: string[]): Promise<void> { return; } if (v.csv) { - const csv = recordsCsv(roll, name, rest.join(" "), v); - if (v.json) return console.log(JSON.stringify({ collection: name, csv }, null, 2)); + const { csv, sealed } = recordsCsv(roll, name, rest.join(" "), v); + if (v.json) return console.log(JSON.stringify({ collection: name, csv, sealed }, null, 2)); process.stdout.write(csv); + // On stderr, so the CSV on stdout stays exactly the file. + if (sealed.length) console.error(yellow(`Sealed ${sealed.length === 1 ? "field" : "fields"} ${sealed.join(", ")} ${sealed.length === 1 ? "is" : "are"} written as ciphertext: only a key opens ${sealed.length === 1 ? "it" : "them"}, and gitroll import csv brings ${sealed.length === 1 ? "it" : "them"} back sealed.`)); return; } const table = recordTable(roll, name, rest.join(" "), v); @@ -861,7 +869,7 @@ async function main(argv: string[]): Promise<void> { printEntry(result.entry, names(roll)); for (const [key, value] of Object.entries(result.entry.meta)) { if (key === "source") continue; - console.log(` ${dim(key)}: ${displayValue(value) ?? (typeof value === "object" ? JSON.stringify(value) : value)}`); + console.log(` ${dim(key)}: ${displayValue(value) ?? (typeof value === "object" ? JSON.stringify(value) : linksAsText(String(value)))}`); } for (const n of result.notices) console.log(yellow(n)); if (result.changed) printCommitMode(roll); @@ -932,9 +940,11 @@ async function main(argv: string[]): Promise<void> { } 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<string>() : 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."); @@ -948,16 +958,16 @@ async function main(argv: string[]): Promise<void> { at = t.path; } if ("derived" in t) console.log(` ${yellow("[ ]")} ${t.text} ${dim("(derived from quantity and reorderAt; not written anywhere)")}`); - else console.log(` ${t.done ? green("[x]") : "[ ]"} ${t.done ? dim(t.text) : t.text} ${dim(`:${t.line}`)}`); + else console.log(` ${t.done ? green("[x]") : "[ ]"} ${t.done ? dim(linksAsText(t.text)) : linksAsText(t.text)} ${dim(`:${t.line}`)}`); } return; } case "todo": { const roll = openRoll(); const result = roll.addTodo(args.join(" "), v.to); - if (v.json) return console.log(JSON.stringify(result, null, 2)); + if (v.json) return console.log(JSON.stringify(masked(result), null, 2)); console.log(`${green("Added")} to ${bold(result.entry.title)} ${dim(`(${eventName(result.entry.path)}:${result.todo.line})`)}`); - console.log(` [ ] ${result.todo.text}`); + console.log(` [ ] ${linksAsText(result.todo.text)}`); printCommitMode(roll); return; } @@ -968,9 +978,9 @@ async function main(argv: string[]): Promise<void> { const ref = args.join(" ").trim(); const { path: at, line } = todoRef(roll, ref, done); const result = roll.markTodo(at, line, done); - if (v.json) return console.log(JSON.stringify(result, null, 2)); - console.log(`${done ? green("Done:") : "Back on the list:"} ${result.todo.text} ${dim(`${eventName(result.entry.path)}:${result.todo.line}`)}`); - if (result.next) console.log(`${green("Next:")} ${result.next.text} ${dim(`${eventName(result.entry.path)}:${result.next.line}`)}`); + if (v.json) return console.log(JSON.stringify(masked(result), null, 2)); + console.log(`${done ? green("Done:") : "Back on the list:"} ${linksAsText(result.todo.text)} ${dim(`${eventName(result.entry.path)}:${result.todo.line}`)}`); + if (result.next) console.log(`${green("Next:")} ${linksAsText(result.next.text)} ${dim(`${eventName(result.entry.path)}:${result.next.line}`)}`); printCommitMode(roll); return; } @@ -1006,7 +1016,7 @@ async function main(argv: string[]): Promise<void> { throw new UserError("Say which one to keep: --mine, --theirs, or --editor to write the version you want."); } const entry = roll.resolveConflict(file, choice); - if (v.json) return console.log(JSON.stringify(entry, null, 2)); + if (v.json) return console.log(JSON.stringify(maskEntry(entry), null, 2)); console.log(green("Settled, as a new commit.") + dim(" The other version is still in this event's history.")); return printEntry(entry, names(roll)); } @@ -1067,9 +1077,9 @@ async function main(argv: string[]): Promise<void> { case "mv": { const roll = openRoll(); const before = roll.entry(need(args[0], "gitroll move <file> <new path>")); - const inbound = roll.documents().filter((x) => x.path !== before.path && x.links.includes(before.path)).length; + const inbound = roll.documents().filter((x) => x.path !== before.path && linksTo(x, before.path)).length; const e = roll.moveEntry(before.path, need(args[1], "gitroll move <file> <new path>")); - if (v.json) return console.log(JSON.stringify({ ...e, relinked: inbound }, null, 2)); + if (v.json) return console.log(JSON.stringify({ ...maskEntry(e), relinked: inbound }, null, 2)); return console.log( `${green("Moved")} to ${e.path}. Links to files were updated; history follows the rename.` + (inbound ? ` ${inbound} ${inbound === 1 ? "event that linked" : "events that linked"} to it now ${inbound === 1 ? "points" : "point"} at the new path.` : ""), @@ -1311,9 +1321,9 @@ async function main(argv: string[]): Promise<void> { const words = args.join(" ").trim(); if (!words) throw new CliError("INVALID_ARGUMENT", 'Usage: gitroll remind "text" --at "2026-11-01 09:00" [--to <note>]'); const result = roll.addTodo(`${words} ⏰ ${at}`, v.to); - if (v.json) return console.log(JSON.stringify({ ...result, at }, null, 2)); + if (v.json) return console.log(JSON.stringify({ ...masked(result), at }, null, 2)); console.log(`${green("Reminder added")} to ${bold(result.entry.title)} ${dim(`(${eventName(result.entry.path)}:${result.todo.line})`)}`); - console.log(` [ ] ${result.todo.text}`); + console.log(` [ ] ${linksAsText(result.todo.text)}`); console.log(dim("GitRoll tells you when you run it (gitroll reminders, gitroll upcoming), or a calendar app does from gitroll calendar --ics.")); printCommitMode(roll); return; @@ -1365,9 +1375,11 @@ async function main(argv: string[]): Promise<void> { } case "contacts": { const roll = openRoll(); - const view = contactsView(roll, args.join(" "), v); + const view = await contactsView(roll, args.join(" "), v); if (v.vcf && !v.json) { process.stdout.write(view.vcf!); + // On stderr, so the vCard on stdout stays exactly the file. + if (view.sealed?.length) console.error(yellow(`Left out, because no key on this computer opens ${view.sealed.length === 1 ? "it" : "them"}: ${view.sealed.map((s) => `${s.field} in ${s.path.replace(/^\.gitroll\//, "")}`).join(", ")}. With the key here, sealed properties are written opened.`)); return; } if (v.json) return console.log(JSON.stringify(view, null, 2)); @@ -2147,15 +2159,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<string, string | boolean | string[] | undefined>): void { const config = loadUserConfig(); diff --git a/src/node/sealing.ts b/src/node/sealing.ts index 7c0b6e9..ba30e3e 100644 --- a/src/node/sealing.ts +++ b/src/node/sealing.ts @@ -8,6 +8,7 @@ import fs from "node:fs"; import path from "node:path"; +import { parse } from "yaml"; import { AgeError, X25519Identity, X25519Recipient, armor, dearmor, decrypt, decryptAny, encrypt, isArmored, parseHeader, parseIdentities } from "../core/age/format.ts"; import { fromUtf8, utf8 } from "../core/age/bytes.ts"; import { FormatError, retargetLinks, splitSource } from "../core/entry.ts"; @@ -663,6 +664,30 @@ export async function presentEntry<T extends LoadedEntry>(entry: T, opts: { sour return maskEntry(entry, opts.source, texts); } +/** + * A document with the sealed fields named opened, for an export the person + * holding the key asked for, such as a vCard of their own address book: each + * one a key on this computer opens holds its value again, and `closed` names + * those that stay sealed. Only the copy in memory changes; nothing is written. + */ +export async function openFields<T extends LoadedEntry>(entry: T, fields: string[]): Promise<{ entry: T; closed: string[] }> { + const want = new Set(fields.map((f) => f.toLowerCase())); + const sealed = sealedFields(entry.meta).filter((f) => want.has(f.toLowerCase())); + if (!sealed.length) return { entry, closed: [] }; + const identities = safeIdentities(); + const meta: Record<string, unknown> = { ...entry.meta }; + const closed: string[] = []; + for (const field of sealed) { + try { + meta[field] = parse(fromUtf8(await decryptAny(String(entry.meta[field]), identities, crypto))) as unknown; + } catch { + // No key here opens it, or it is damaged: it stays sealed. + closed.push(field); + } + } + return { entry: { ...entry, meta }, closed }; +} + /** The same, without opening anything: what every list and search returns. Synchronous. */ export function maskEntry<T extends LoadedEntry>(entry: T, source?: string, texts: Map<string, string> = new Map()): T & { sealed?: SealedPart[] } { const fields = sealedFields(entry.meta); @@ -682,6 +707,10 @@ export function maskEntry<T extends LoadedEntry>(entry: T, source?: string, text return { ...entry, meta, sealed: parts }; } +/** Front matter fields as a reader without a key sees them: each sealed one `{sealed: true}`. */ +export const maskFields = (meta: Record<string, unknown>): Record<string, unknown> => + Object.fromEntries(Object.entries(meta).map(([k, v]) => [k, isSealedValue(v) ? { sealed: true } : v])); + /** The body as shown to a person: each sealed block replaced by its plaintext, or by a placeholder. */ export function displayBody(body: string, parts?: SealedPart[]): string { const texts = (parts ?? []).filter((p) => p.kind === "block"); 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 <name>` 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<Scope>("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<string, number>(); @@ -467,155 +474,159 @@ export function App({ store }: { store: Store }) { </div> </header> - <main id="main" tabIndex={-1} className="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-4 outline-none"> - <Banners connection={connection} warnings={info.warnings} problems={info.problems} /> + <SavedSearchesContext.Provider value={savedList}> + <main id="main" tabIndex={-1} className="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-4 outline-none"> + <Banners connection={connection} warnings={info.warnings} problems={info.problems} /> - {route.name === "timeline" && ( - <> - <div ref={composerAnchor}> - <Composer - value={value} - onChange={setValue} - projects={projects} - templates={info.templates} - editing={null} - maxAttachmentBytes={info.maxAttachmentBytes} - attachmentUrl={attachmentUrl} - onSubmit={() => void save()} - saving={saving} - collapsible - expanded={composerOpen} - onExpandedChange={setComposerOpen} - /> - </div> - - <QueryBar - query={query} - onQueryChange={setQuery} - quickFilters={info.filters} - suggestCtx={suggestCtx} - projectName={projectName} - resultCount={results.length} - totals={totals} - inputRef={searchRef} - scope={rollsStore ? { value: scope, onChange: setScope } : undefined} - status={everywhere ? allRollsStatus(allRolls) : undefined} - extraFilters={ - searchesStore && <SavedSearchChips store={searchesStore} list={saved.list} reload={saved.reload} query={query} onQueryChange={setQuery} /> - } - /> + {route.name === "timeline" && ( + <> + <div ref={composerAnchor}> + <Composer + value={value} + onChange={setValue} + projects={projects} + templates={info.templates} + editing={null} + maxAttachmentBytes={info.maxAttachmentBytes} + attachmentUrl={attachmentUrl} + onSubmit={() => void save()} + saving={saving} + collapsible + expanded={composerOpen} + onExpandedChange={setComposerOpen} + /> + </div> - {everywhere ? ( - <AllRollsResults store={rollsStore} query={query} result={allRolls} /> - ) : ( - <Timeline - entries={results} + <QueryBar + query={query} + onQueryChange={setQuery} + quickFilters={info.filters} + suggestCtx={suggestCtx} projectName={projectName} - attachmentUrl={attachmentUrl} - onFilter={onFilter} - emptyState={ - query.trim() ? ( - <div className="flex flex-col items-start gap-2 py-10"> - <p className="text-sm">{COPY.noMatches}</p> - <p className="text-sm text-muted-foreground">{COPY.noMatchesHint}</p> - <Button variant="secondary" size="sm" onClick={() => setQuery("")}> - {COPY.clearFilters} - </Button> - </div> - ) : ( - <div className="flex flex-col items-start gap-2 py-10"> - <p className="text-base font-medium">{COPY.emptyTitle}</p> - <p className="max-w-prose text-sm text-muted-foreground">{COPY.emptyBody}</p> - </div> - ) + resultCount={results.length} + totals={totals} + inputRef={searchRef} + scope={rollsStore ? { value: scope, onChange: setScope } : undefined} + status={everywhere ? allRollsStatus(allRolls) : undefined} + extraFilters={ + searchesStore && <SavedSearchChips store={searchesStore} list={saved.list} reload={saved.reload} query={query} onQueryChange={setQuery} /> } /> - )} - </> - )} + <QueryError error={searched.error} /> + + {everywhere ? ( + <AllRollsResults store={rollsStore} query={query} result={allRolls} /> + ) : ( + <Timeline + entries={results} + resolved={resolved} + projectName={projectName} + attachmentUrl={attachmentUrl} + onFilter={onFilter} + emptyState={ + query.trim() ? ( + <div className="flex flex-col items-start gap-2 py-10"> + <p className="text-sm">{COPY.noMatches}</p> + <p className="text-sm text-muted-foreground">{COPY.noMatchesHint}</p> + <Button variant="secondary" size="sm" onClick={() => setQuery("")}> + {COPY.clearFilters} + </Button> + </div> + ) : ( + <div className="flex flex-col items-start gap-2 py-10"> + <p className="text-base font-medium">{COPY.emptyTitle}</p> + <p className="max-w-prose text-sm text-muted-foreground">{COPY.emptyBody}</p> + </div> + ) + } + /> + )} + </> + )} - {route.name === "topics" && <TopicsPage entries={entries} projects={projects} onCreate={() => void newTopic()} />} + {route.name === "topics" && <TopicsPage entries={entries} projects={projects} onCreate={() => void newTopic()} />} - {route.name === "conflicts" && <Conflicts store={store} onResolved={storeChanged} />} + {route.name === "conflicts" && <Conflicts store={store} onResolved={storeChanged} />} - {route.name === "deleted" && <DeletedPage store={store} onRestored={storeChanged} />} + {route.name === "deleted" && <DeletedPage store={store} onRestored={storeChanged} />} - {VIEW_PAGES.has(route.name) && - (!viewsStore ? ( - <p className="text-sm text-muted-foreground">This page isn't available here yet. The GitRoll app on your computer has it.</p> - ) : !views.data ? ( - <ViewsState error={views.error} /> - ) : ( - <> - {route.name === "notes" && <NotesPage notes={notes} onNewNote={addNote} />} - {route.name === "records" && ( - <RecordsPage - notes={notes} - collection={route.collection} - revisions={views.data.revisions} - onNewRecord={addRecord} - onSetField={setField} - /> - )} - {route.name === "upcoming" && ( - <UpcomingPage - docs={docs} - todos={views.data.todos} - notes={notes} - calendarUrl={viewsStore.calendarUrl()} - onMark={markTodo} - onAddTodo={addTodo} - /> - )} - {route.name === "ledger" && <LedgerPage docs={docs} />} - {route.name === "series" && <SeriesPage docs={docs} field={route.field} />} - {route.name === "inventory" && <InventoryPage notes={notes} docs={docs} />} - {route.name === "contacts" && <ContactsPage notes={notes} docs={docs} />} - {route.name === "organizations" && <OrganizationsPage notes={notes} docs={docs} />} - {route.name === "places" && <PlacesPage notes={notes} docs={docs} />} - {route.name === "issues" && <IssuesPage docs={docs} onResolve={resolveIssue ? (issue) => void resolveIssue(issue) : undefined} />} - {route.name === "files" && ( - <FilesPage files={views.data.files} fileUrl={(path) => store.attachmentUrl({ path, name: path, type: "", image: false })} /> - )} - </> - ))} + {VIEW_PAGES.has(route.name) && + (!viewsStore ? ( + <p className="text-sm text-muted-foreground">This page isn't available here yet. The GitRoll app on your computer has it.</p> + ) : !views.data ? ( + <ViewsState error={views.error} /> + ) : ( + <> + {route.name === "notes" && <NotesPage notes={notes} onNewNote={addNote} />} + {route.name === "records" && ( + <RecordsPage + notes={notes} + collection={route.collection} + revisions={views.data.revisions} + onNewRecord={addRecord} + onSetField={setField} + /> + )} + {route.name === "upcoming" && ( + <UpcomingPage + docs={withFiles} + todos={views.data.todos} + notes={notes} + calendarUrl={viewsStore.calendarUrl()} + onMark={markTodo} + onAddTodo={addTodo} + /> + )} + {route.name === "ledger" && <LedgerPage docs={docs} />} + {route.name === "series" && <SeriesPage docs={docs} field={route.field} />} + {route.name === "inventory" && <InventoryPage notes={notes} docs={docs} />} + {route.name === "contacts" && <ContactsPage notes={notes} docs={docs} />} + {route.name === "organizations" && <OrganizationsPage notes={notes} docs={docs} />} + {route.name === "places" && <PlacesPage notes={notes} docs={withFiles} />} + {route.name === "issues" && <IssuesPage docs={docs} onResolve={resolveIssue ? (issue) => void resolveIssue(issue) : undefined} />} + {route.name === "files" && ( + <FilesPage files={views.data.files} fileUrl={(path) => store.attachmentUrl({ path, name: path, type: "", image: false })} /> + )} + </> + ))} - {route.name === "entry" && ( - <EntryDetail - entry={entry} - pending={entryPending} - entries={docs} - notesRead={!viewsStore || !!views.data} - projectName={projectName} - attachmentUrl={attachmentUrl} - onFilter={onFilter} - onEdit={() => { - 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} - /> - )} - </main> + {route.name === "entry" && ( + <EntryDetail + entry={entry} + pending={entryPending} + entries={withFiles} + notesRead={!viewsStore || !!views.data} + projectName={projectName} + attachmentUrl={attachmentUrl} + onFilter={onFilter} + onEdit={() => { + 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} + /> + )} + </main> + </SavedSearchesContext.Provider> {/* 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 <div className="flex min-w-48 flex-col gap-1.5"> <Label htmlFor="contacts-q">Filter</Label> <Input id="contacts-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: org:acme has:bday" /> + <QueryError error={saved.error} /> </div> <p className="text-sm text-muted-foreground" aria-live="polite"> diff --git a/src/web/components/EntryDetail.tsx b/src/web/components/EntryDetail.tsx index f6db9af..282f5b0 100644 --- a/src/web/components/EntryDetail.tsx +++ b/src/web/components/EntryDetail.tsx @@ -9,7 +9,7 @@ import type { Issue } from "../../core/issues.ts"; import { isPinned } from "../../core/pins.ts"; import { related } from "../../core/relations.ts"; import { isoDate } from "../../core/util.ts"; -import { DocLink, dayText } from "./ViewParts.tsx"; +import { DocLink, LinkedText, dayText } from "./ViewParts.tsx"; import { recordsHref } from "../hooks/useStore.ts"; import { fileKind, fmtAmount, isImage, message, plural } from "../lib/format.ts"; import { contextFor, linkedPaths } from "../lib/markdown.ts"; @@ -180,7 +180,7 @@ export function EntryDetail({ {rows.map(([k, v]) => ( <div key={k} className="contents"> <dt className="text-muted-foreground">{k}</dt> - <dd>{sealedFields.has(k) ? <SealedField armored={sealedFields.get(k)!} fallback={v} /> : v}</dd> + <dd>{sealedFields.has(k) ? <SealedField armored={sealedFields.get(k)!} fallback={v} /> : <LinkedText text={v} from={e.path} />}</dd> </div> ))} </dl> @@ -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) => ( <li key={e.path}> <a - href={`#/entry/${encodeURIComponent(e.path)}`} + href={isFile(e) ? "#/files" : `#/entry/${encodeURIComponent(e.path)}`} className="flex items-baseline gap-2 rounded px-1 py-0.5 text-sm hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" > <span className="truncate">{e.title}</span> - <span className="shrink-0 text-xs text-muted-foreground">{e.path.startsWith(".gitroll/notes/") ? "note" : (e.date ?? "undated")}</span> + <span className="shrink-0 text-xs text-muted-foreground">{isFile(e) ? "file" : e.path.startsWith(".gitroll/notes/") ? "note" : (e.date ?? "undated")}</span> </a> </li> ); 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 <div className="flex min-w-48 flex-1 flex-col gap-1.5"> <Label htmlFor="inventory-q">Filter</Label> <Input id="inventory-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: brand:daikin price>100" /> + <QueryError error={saved.error} /> </div> <Button size="sm" variant={byPlace ? "default" : "ghost"} aria-pressed={byPlace} onClick={() => setByPlace((v) => !v)}> By place diff --git a/src/web/components/IssuesPage.tsx b/src/web/components/IssuesPage.tsx index 54ece63..dc770be 100644 --- a/src/web/components/IssuesPage.tsx +++ b/src/web/components/IssuesPage.tsx @@ -9,6 +9,7 @@ import { plural } from "../lib/format.ts"; import { DocLink, Empty, PageHeader, dayText, shortPath, 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"; const linkClass = "rounded underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"; @@ -38,9 +39,10 @@ function State({ issue }: { issue: Issue }) { */ export function IssuesPage({ docs, onResolve }: { docs: LoadedEntry[]; onResolve?: (issue: Issue) => void }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const [all, setAll] = useState(false); const today = isoDate(); - const view = useMemo(() => issues(query.trim() ? new SearchIndex(docs).search(query) : docs, docs, today, { all }), [docs, query, all, today]); + const view = useMemo(() => issues(saved.query.trim() ? new SearchIndex(docs).search(saved.query) : docs, docs, today, { all }), [docs, saved.query, all, today]); const any = useMemo(() => docs.some(isIssue), [docs]); if (!any) { @@ -66,6 +68,7 @@ export function IssuesPage({ docs, onResolve }: { docs: LoadedEntry[]; onResolve <div className="flex min-w-48 flex-1 flex-col gap-1.5"> <Label htmlFor="issues-q">Filter</Label> <Input id="issues-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: topic:car tag:leak" /> + <QueryError error={saved.error} /> </div> <label className="flex items-center gap-2 pb-2 text-sm"> <input type="checkbox" checked={all} onChange={(e) => setAll(e.target.checked)} className="size-4 accent-primary" /> diff --git a/src/web/components/LedgerPage.tsx b/src/web/components/LedgerPage.tsx index 581aea9..f09a37c 100644 --- a/src/web/components/LedgerPage.tsx +++ b/src/web/components/LedgerPage.tsx @@ -7,6 +7,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"; const GROUPS = [ { by: "", label: "Every entry" }, @@ -17,17 +18,19 @@ const GROUPS = [ ]; /** - * Totals of every event's `amount` and every record's `price`, per currency + * Totals of every event's `amount`, and the `price` of every record whose + * purchase isn't one of those events linking to it, per currency * and never converted. A view, not an accounting system: `gitroll ledger * --hledger` hands the same entries to one. */ export function LedgerPage({ docs }: { docs: LoadedEntry[] }) { const [by, setBy] = useState("month"); const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const view = useMemo(() => { - const chosen = query.trim() ? new SearchIndex(docs).search(query) : docs; - return ledger(chosen, by || undefined); - }, [docs, by, query]); + const chosen = saved.query.trim() ? new SearchIndex(docs).search(saved.query) : docs; + return ledger(chosen, by || undefined, docs); + }, [docs, by, saved.query]); const newestFirst = useMemo(() => [...view.entries].sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")), [view.entries]); const groups = useMemo(() => (by === "month" || by === "year" ? [...view.groups].reverse() : view.groups), [view.groups, by]); @@ -39,6 +42,7 @@ export function LedgerPage({ docs }: { docs: LoadedEntry[] }) { <div className="flex min-w-48 flex-1 flex-col gap-1.5"> <Label htmlFor="ledger-q">Filter</Label> <Input id="ledger-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="topic:house after:2026-01-01" /> + <QueryError error={saved.error} /> </div> <div role="group" aria-label="Group by" className="flex flex-wrap gap-1"> {GROUPS.map((g) => ( diff --git a/src/web/components/NotesPage.tsx b/src/web/components/NotesPage.tsx index 966abd7..ea998b7 100644 --- a/src/web/components/NotesPage.tsx +++ b/src/web/components/NotesPage.tsx @@ -12,6 +12,7 @@ import { Button } from "./ui/button.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog.tsx"; import { Field, Input, Label, Textarea } from "./ui/input.tsx"; import { useToast } from "./ui/toast.tsx"; +import { QueryError, useSavedQuery } from "./SavedSearches.tsx"; const rowClass = "flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5 transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"; @@ -30,11 +31,12 @@ export function NotesPage({ onNewNote?: (input: { title: string; text: string }) => Promise<void>; }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const [writing, setWriting] = useState(false); const groups = useMemo(() => collections(notes), [notes]); const loose = useMemo(() => notes.filter((n) => collectionOf(n.path) === null), [notes]); const index = useMemo(() => new SearchIndex(notes), [notes]); - const hits = useMemo(() => (query.trim() ? index.search(query) : null), [index, query]); + const hits = useMemo(() => (saved.query.trim() ? index.search(saved.query) : null), [index, saved.query]); return ( <div className="flex flex-col gap-5"> @@ -60,6 +62,7 @@ export function NotesPage({ <div className="flex flex-col gap-1.5"> <Label htmlFor="notes-q">Search notes</Label> <Input id="notes-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or a field like rating>=4" /> + <QueryError error={saved.error} /> </div> {hits ? ( diff --git a/src/web/components/OrganizationsPage.tsx b/src/web/components/OrganizationsPage.tsx index b734cbd..d5e97cb 100644 --- a/src/web/components/OrganizationsPage.tsx +++ b/src/web/components/OrganizationsPage.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"; @@ -19,8 +20,9 @@ const isWeb = (s: string) => /^https?:\/\//i.test(s); */ export function OrganizationsPage({ notes, docs }: { notes: LoadedEntry[]; docs: LoadedEntry[] }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const records = useMemo(() => recordsIn(notes, ORGANIZATIONS_COLLECTION), [notes]); - const view = useMemo(() => organizations(query.trim() ? new SearchIndex(records).search(query) : records, docs), [records, docs, query]); + const view = useMemo(() => organizations(saved.query.trim() ? new SearchIndex(records).search(saved.query) : records, docs), [records, docs, saved.query]); if (!records.length) { return ( @@ -38,12 +40,13 @@ export function OrganizationsPage({ notes, docs }: { notes: LoadedEntry[]; docs: <div className="flex flex-col gap-4"> <PageHeader title="Organizations" - description="Records in .gitroll/notes/organizations/. People are the contacts whose org names or links to it; last contacted is the newest event that links to it." + description="Records in .gitroll/notes/organizations/. People are the contacts whose org names or links to it; supplied, the things whose vendor links to it; last contacted is the newest event that links to it." /> <div className="flex min-w-48 flex-col gap-1.5"> <Label htmlFor="organizations-q">Filter</Label> <Input id="organizations-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: has:url foundingDate<2000" /> + <QueryError error={saved.error} /> </div> <p className="text-sm text-muted-foreground" aria-live="polite"> @@ -55,7 +58,7 @@ export function OrganizationsPage({ notes, docs }: { notes: LoadedEntry[]; docs: <caption className="sr-only">Organizations</caption> <thead> <tr> - {["Name", "Website", "Phone and email", "People", "Last contacted"].map((h) => ( + {["Name", "Website", "Phone and email", "People", "Supplied", "Last contacted"].map((h) => ( <th key={h} scope="col" className={thClass}> {h} </th> @@ -109,6 +112,13 @@ export function OrganizationsPage({ notes, docs }: { notes: LoadedEntry[]; docs: </span> ))} </td> + <td className={tdClass}> + {o.supplied.map((s) => ( + <DocLink key={s.path} path={s.path} className={`${linkClass} block`}> + {s.title} + </DocLink> + ))} + </td> <td className={`${tdClass} whitespace-nowrap`}> {o.lastContacted && o.interactions[0] ? <DocLink path={o.interactions[0].path} className={linkClass}>{dayText(o.lastContacted)}</DocLink> : ""} </td> diff --git a/src/web/components/PlacesPage.tsx b/src/web/components/PlacesPage.tsx index ae9a607..410415f 100644 --- a/src/web/components/PlacesPage.tsx +++ b/src/web/components/PlacesPage.tsx @@ -7,13 +7,14 @@ import { SearchIndex } from "../../core/search.ts"; import { plural } from "../lib/format.ts"; import { DocLink, Empty, PageHeader, dayText } 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"; /** How many events each place lists before saying how many more there are. */ const EVENTS_SHOWN = 5; -function Links({ label, list }: { label: string; list: Here[] }) { +function Links({ label, list, files = false }: { label: string; list: Here[]; files?: boolean }) { if (!list.length) return null; return ( <p> @@ -21,9 +22,16 @@ function Links({ label, list }: { label: string; list: Here[] }) { {list.map((h, i) => ( <span key={h.path}> {i > 0 && ", "} - <DocLink path={h.path} className={linkClass}> - {h.title} - </DocLink> + {files ? ( + // A file has no page of its own; the Files page lists it with its fields. + <a href="#/files" className={linkClass}> + {h.title} + </a> + ) : ( + <DocLink path={h.path} className={linkClass}> + {h.title} + </DocLink> + )} </span> ))} </p> @@ -64,6 +72,7 @@ function PlaceCard({ place, byPath, nested }: { place: PlaceRecord; byPath: Map< <Links label="People" list={p.people} /> <Links label="Organizations" list={p.organizations} /> <Links label="Notes" list={p.notes} /> + <Links label="Files" list={p.files} files /> {p.events.length > 0 && ( <div> <p className="text-muted-foreground">{plural(p.events.length, "event", "events")} here, newest first:</p> @@ -100,8 +109,9 @@ function PlaceCard({ place, byPath, nested }: { place: PlaceRecord; byPath: Map< */ export function PlacesPage({ notes, docs }: { notes: LoadedEntry[]; docs: LoadedEntry[] }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const records = useMemo(() => recordsIn(notes, PLACES_COLLECTION), [notes]); - const view = useMemo(() => places(query.trim() ? new SearchIndex(records).search(query) : records, docs), [records, docs, query]); + const view = useMemo(() => places(saved.query.trim() ? new SearchIndex(records).search(saved.query) : records, docs), [records, docs, saved.query]); const byPath = useMemo(() => new Map(view.places.map((p) => [p.path, p])), [view]); if (!records.length) { @@ -126,6 +136,7 @@ export function PlacesPage({ notes, docs }: { notes: LoadedEntry[]; docs: Loaded <div className="flex min-w-48 flex-col gap-1.5"> <Label htmlFor="places-q">Filter</Label> <Input id="places-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: has:address latitude>50" /> + <QueryError error={saved.error} /> </div> <p className="text-sm text-muted-foreground" aria-live="polite"> diff --git a/src/web/components/RecordsPage.tsx b/src/web/components/RecordsPage.tsx index ba8c26f..feef749 100644 --- a/src/web/components/RecordsPage.tsx +++ b/src/web/components/RecordsPage.tsx @@ -9,11 +9,12 @@ import { COPY } from "../copy.ts"; import { message, plural } from "../lib/format.ts"; import { ChangedOnDiskError } from "../store.ts"; import type { FieldsSaved } from "../store.ts"; -import { DocLink, Empty, PageHeader, fieldText, shortPath, tableClass, tdClass, thClass, useDiscardGuard } from "./ViewParts.tsx"; +import { DocLink, Empty, LinkedText, PageHeader, fieldText, shortPath, tableClass, tdClass, thClass, useDiscardGuard } from "./ViewParts.tsx"; import { Button } from "./ui/button.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog.tsx"; import { Field, Input, Label } from "./ui/input.tsx"; import { useToast } from "./ui/toast.tsx"; +import { QueryError, useSavedQuery } from "./SavedSearches.tsx"; export interface NewRecord { collection: string; @@ -47,6 +48,7 @@ export function RecordsPage({ onSetField?: (path: string, key: string, value: string, revision: string) => Promise<FieldsSaved>; }) { const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const [sort, setSort] = useState<{ key: string; descending: boolean } | null>(null); const [adding, setAdding] = useState(false); const toast = useToast(); @@ -54,9 +56,9 @@ export function RecordsPage({ const records = useMemo(() => recordsIn(notes, collection), [notes, collection]); const columns = useMemo(() => columnsOf(records), [records]); const rows = useMemo(() => { - const found = query.trim() ? new SearchIndex(records).search(query) : records; + const found = saved.query.trim() ? new SearchIndex(records).search(saved.query) : records; return sort ? sortByFields(found, [sort]) : [...found].sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true })); - }, [records, query, sort]); + }, [records, saved.query, sort]); const title = info?.name ?? collection; const addButton = onNewRecord && title && ( @@ -118,6 +120,7 @@ export function RecordsPage({ onChange={(e) => setQuery(e.target.value)} placeholder="Words, or fields: status:reading rating>=4 has:isbn" /> + <QueryError error={saved.error} /> </div> {records.length === 0 ? ( @@ -162,6 +165,7 @@ export function RecordsPage({ <FieldCell key={c} value={r.meta[c]} + from={r.path} label={`${c} of ${r.title || shortPath(r.path)}`} editable={!!onSetField && !!revisions?.[r.path]} onSave={(value) => saveField(r, c, value)} @@ -183,7 +187,7 @@ export function RecordsPage({ * Escape puts it back, and an empty box removes the field. A sealed value, or * a mapping such as an amount, isn't one line of text and stays read-only. */ -function FieldCell({ value, label, editable, onSave }: { value: unknown; label: string; editable: boolean; onSave(text: string): Promise<boolean> }) { +function FieldCell({ value, from, label, editable, onSave }: { value: unknown; from: string; label: string; editable: boolean; onSave(text: string): Promise<boolean> }) { const initial = fieldYaml(value); const [draft, setDraft] = useState<string | null>(null); const [saving, setSaving] = useState(false); @@ -198,7 +202,8 @@ function FieldCell({ value, label, editable, onSave }: { value: unknown; label: } }, [draft]); - if (!editable || initial === null || isSealedValue(value)) return <td className={tdClass}>{fieldText(value)}</td>; + // Read-only, a link in the value is a link; in a cell that edits on click it is its text. + if (!editable || initial === null || isSealedValue(value)) return <td className={tdClass}>{typeof value === "string" && !isSealedValue(value) ? <LinkedText text={value} from={from} /> : fieldText(value)}</td>; const close = (focusCell: boolean) => { settled.current = true; diff --git a/src/web/components/SavedSearches.tsx b/src/web/components/SavedSearches.tsx index 353b08a..67c1180 100644 --- a/src/web/components/SavedSearches.tsx +++ b/src/web/components/SavedSearches.tsx @@ -1,5 +1,7 @@ import { Bookmark, BookmarkPlus, Pencil, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { expandSavedSearches } from "../../core/search.ts"; +import { slugify } from "../../core/util.ts"; import { message } from "../lib/format.ts"; import { cn } from "../lib/utils.ts"; import type { SavedSearchesStore } from "../store.ts"; @@ -19,13 +21,15 @@ import { useToast } from "./ui/toast.tsx"; export type SavedList = [name: string, query: string][]; -/** The saved searches, read again whenever the Roll changes or the window comes back. */ +/** The saved searches, read again whenever the Roll changes or the window comes back. `loaded` is false until they have been read once. */ export function useSavedSearches(store: SavedSearchesStore | null, version: string) { const [list, setList] = useState<SavedList>([]); + const [loaded, setLoaded] = useState(false); const reload = useCallback(async () => { if (!store) return; try { setList(Object.entries(await store.savedSearches())); + setLoaded(true); } catch { // Kept as they were; the connection banner says when the app has stopped. } @@ -35,7 +39,45 @@ export function useSavedSearches(store: SavedSearchesStore | null, version: stri window.addEventListener("focus", reload); return () => window.removeEventListener("focus", reload); }, [reload, version]); - return { list, reload }; + return { list, loaded, reload }; +} + +/** + * The saved searches every page's search box reads `@name` from; null where + * the store keeps none (or hasn't read them yet), and `@name` is then left as + * it was typed. + */ +export const SavedSearchesContext = createContext<SavedList | null>(null); + +/** + * A query with each `@name` replaced by its saved search, as `gitroll` reads + * one in any command that takes a query, and the error to show when a name + * isn't saved: an unknown name is said so, not shown as nothing found. + */ +export function useSavedQuery(query: string): { query: string; error: string } { + const list = useContext(SavedSearchesContext); + return useMemo(() => savedQuery(list, query), [list, query]); +} + +/** useSavedQuery, for the component that holds the list rather than reading it from context. */ +export function savedQuery(list: SavedList | null, query: string): { query: string; error: string } { + if (!list || !query.includes("@")) return { query, error: "" }; + const byName = new Map(list); + try { + return { query: expandSavedSearches(query, (name) => byName.get(slugify(name))), error: "" }; + } catch (err) { + return { query, error: message(err) }; + } +} + +/** Says why a search box's `@name` found nothing. */ +export function QueryError({ error }: { error: string }) { + if (!error) return null; + return ( + <p role="alert" className="text-sm text-destructive"> + {error} + </p> + ); } const chipClass = (on: boolean) => diff --git a/src/web/components/SeriesPage.tsx b/src/web/components/SeriesPage.tsx index 05ae6d0..111ce42 100644 --- a/src/web/components/SeriesPage.tsx +++ b/src/web/components/SeriesPage.tsx @@ -8,6 +8,7 @@ import { navigate } from "../hooks/useStore.ts"; import { DocLink, Empty, PageHeader, dayText, shortPath, 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"; const GROUPS: { by: SeriesBy | ""; label: string }[] = [ { by: "", label: "Every reading" }, @@ -29,11 +30,12 @@ export function SeriesPage({ docs, field: wanted }: { docs: LoadedEntry[]; field const chosen = field || fields[0]?.name || ""; const [by, setBy] = useState<SeriesBy | "">(""); const [query, setQuery] = useState(""); + const saved = useSavedQuery(query); const view = useMemo(() => { if (!chosen) return null; - const filtered = query.trim() ? new SearchIndex(docs).search(query) : docs; + const filtered = saved.query.trim() ? new SearchIndex(docs).search(saved.query) : docs; return series(filtered, chosen, by || null); - }, [docs, chosen, by, query]); + }, [docs, chosen, by, saved.query]); if (!fields.length && !wanted) { return ( @@ -71,6 +73,7 @@ export function SeriesPage({ docs, field: wanted }: { docs: LoadedEntry[]; field <div className="flex min-w-48 flex-1 flex-col gap-1.5"> <Label htmlFor="series-q">Filter</Label> <Input id="series-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="tag:car after:2026-01-01" /> + <QueryError error={saved.error} /> </div> <div role="group" aria-label="Group by" className="flex flex-wrap gap-1"> {GROUPS.map((g) => ( diff --git a/src/web/components/Timeline.tsx b/src/web/components/Timeline.tsx index a0c96b4..e8d48b4 100644 --- a/src/web/components/Timeline.tsx +++ b/src/web/components/Timeline.tsx @@ -2,12 +2,14 @@ import { Paperclip } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { LoadedEntry } from "../../core/layout.ts"; import { isPinned, pinnedFirst } from "../../core/pins.ts"; +import { isIssue } from "../../core/issues.ts"; import { SEALED_PLACEHOLDER, isSealedValue } from "../../core/sealed.ts"; import { COPY } from "../copy.ts"; import { dateOf, dayLabel, fmtAmount, isImage, plural } from "../lib/format.ts"; import { contextFor, linkedPaths, markdownToText, renderMarkdown } from "../lib/markdown.ts"; import { tagsIn } from "./Composer.tsx"; import type { Attachment } from "../../core/entry.ts"; +import { linksAsText } from "../../core/entry.ts"; import { Badge } from "./ui/badge.tsx"; import { Button } from "./ui/button.tsx"; @@ -28,9 +30,11 @@ export interface TimelineProps { attachmentUrl(a: Attachment): string; onFilter(key: string, value: string): void; emptyState: React.ReactNode; + /** The issues that are resolved, by path (resolvedIssues), so each issue says whether it is open. */ + resolved?: Set<string>; } -export function Timeline({ entries, projectName, attachmentUrl, onFilter, emptyState }: TimelineProps) { +export function Timeline({ entries, projectName, attachmentUrl, onFilter, emptyState, resolved }: TimelineProps) { const [shown, setShown] = useState(PAGE_SIZE); const sentinel = useRef<HTMLDivElement>(null); @@ -83,7 +87,7 @@ export function Timeline({ entries, projectName, attachmentUrl, onFilter, emptyS <ul className="flex flex-col"> {day.entries.map((e) => ( <li key={e.id}> - <EntryCard entry={e} projectName={projectName} attachmentUrl={attachmentUrl} onFilter={onFilter} /> + <EntryCard entry={e} projectName={projectName} attachmentUrl={attachmentUrl} onFilter={onFilter} resolved={resolved} /> </li> ))} </ul> @@ -109,9 +113,10 @@ interface EntryCardProps { projectName(slug: string): string; attachmentUrl(a: Attachment): string; onFilter(key: string, value: string): void; + resolved?: Set<string>; } -export function EntryCard({ entry: e, projectName, attachmentUrl, onFilter }: EntryCardProps) { +export function EntryCard({ entry: e, projectName, attachmentUrl, onFilter, resolved }: EntryCardProps) { // A photo embedded in the text is already on screen; don't show it twice. const shown = linkedPaths(e.body, e.path); const images = e.attachments.filter((a) => isImage(a) && !a.image && !shown.has(a.path)); @@ -156,6 +161,8 @@ export function EntryCard({ entry: e, projectName, attachmentUrl, onFilter }: En {fmtAmount(e.amount)} </Badge> )} + {/* An issue says whether it is still open, not the issue: field it was marked with. */} + {isIssue(e) && resolved && <Badge variant="outline">{resolved.has(e.path) ? "Resolved issue" : "Open issue"}</Badge>} </div> {e.body.trim() && ( @@ -171,7 +178,7 @@ export function EntryCard({ entry: e, projectName, attachmentUrl, onFilter }: En {rows.map(([k, v]) => ( <div key={k} className="flex gap-1"> <dt className="text-muted-foreground">{k}</dt> - <dd className="text-foreground">{v}</dd> + <dd className="text-foreground">{linksAsText(v)}</dd> </div> ))} </dl> @@ -242,9 +249,12 @@ function clamp(body: string, max = 600): string { export function fieldRows(e: LoadedEntry): [string, string][] { // `pinned` is shown by where the entry is listed, and on its page, rather than as a row. const known = new Set(["date", "projects", "project", "tags", "tag", "amount", "currency", "title", "source", "pinned"]); + // An issue's `issue` and `resolved` are shown as its status, which an event that resolves it changes too. + const issue = isIssue(e); const rows: [string, string][] = []; for (const [k, v] of Object.entries(e.meta)) { if (known.has(k) || v == null || v === "") continue; + if (issue && ["issue", "resolved"].includes(k.toLowerCase())) continue; // A sealed field is its placeholder, never its ciphertext: a host that can // open it (src/web/unseal.tsx) shows it opened instead. rows.push([k.replace(/_/g, " "), isSealedValue(v) ? SEALED_PLACEHOLDER : typeof v === "object" ? JSON.stringify(v) : String(v)]); diff --git a/src/web/components/UpcomingPage.tsx b/src/web/components/UpcomingPage.tsx index a7b9566..31a6b43 100644 --- a/src/web/components/UpcomingPage.tsx +++ b/src/web/components/UpcomingPage.tsx @@ -4,13 +4,15 @@ import type * as React from "react"; import type { LoadedEntry } from "../../core/layout.ts"; import { taskDates } from "../../core/calendar.ts"; import { upcomingWithReminders } from "../../core/reminders.ts"; +import { withoutResolved } from "../../core/issues.ts"; import type { CalendarItem } from "../../core/calendar.ts"; import { restockTodos } from "../../core/inventory.ts"; import type { Todo } from "../../core/todos.ts"; import { isoDate } from "../../core/util.ts"; import { COPY } from "../copy.ts"; import { message, plural } from "../lib/format.ts"; -import { DocLink, Empty, PageHeader, dayText, shortPath } from "./ViewParts.tsx"; +import { linksAsText } from "../../core/entry.ts"; +import { DocLink, Empty, LinkedText, PageHeader, dayText, shortPath } from "./ViewParts.tsx"; import { ReminderNotifier } from "./ReminderNotifier.tsx"; import { Button } from "./ui/button.tsx"; import { Field, Input } from "./ui/input.tsx"; @@ -42,7 +44,7 @@ export function UpcomingPage({ onMark, onAddTodo, }: { - /** Events and notes. */ + /** Events, notes and files' sidecars. */ docs: LoadedEntry[]; todos: TodoLine[]; notes: LoadedEntry[]; @@ -61,9 +63,11 @@ export function UpcomingPage({ return () => clearInterval(timer); }, []); const today = isoDate(new Date(now)); - const items = useMemo(() => upcomingWithReminders(docs, todos, today, days, new Date(now)), [docs, todos, today, days, now]); + // A resolved issue's to-dos, reminders and repeats are dealt with, as in `gitroll upcoming`. + const sources = useMemo(() => withoutResolved(docs, todos), [docs, todos]); + const items = useMemo(() => upcomingWithReminders(sources.docs, sources.todos, today, days, new Date(now)), [sources, today, days, now]); const due = useMemo(() => items.filter((i) => i.due).map((i) => ({ key: itemKey(i), title: i.title })), [items]); - const open = useMemo(() => todos.filter((t) => !t.done), [todos]); + const open = useMemo(() => sources.todos.filter((t) => !t.done), [sources]); const restock = useMemo(() => restockTodos(notes), [notes]); const mark = async (t: { path: string; line: number }, done: boolean) => { @@ -131,7 +135,7 @@ export function UpcomingPage({ {list.map((i) => ( <li key={itemKey(i)} className="flex items-start gap-2 rounded-lg border border-border px-3 py-2"> {(i.kind === "todo" || i.kind === "reminder") && i.line !== undefined && ( - <TodoBox label={i.title} checked={busy === `${i.path}:${i.line}`} disabled={busy === `${i.path}:${i.line}`} onChange={(d) => void mark({ path: i.path, line: i.line! }, d)} /> + <TodoBox label={linksAsText(i.title)} checked={busy === `${i.path}:${i.line}`} disabled={busy === `${i.path}:${i.line}`} onChange={(d) => void mark({ path: i.path, line: i.line! }, d)} /> )} <div className="min-w-0 flex-1"> <p className="text-sm"> @@ -141,7 +145,7 @@ export function UpcomingPage({ <span className="text-muted-foreground">: {i.field}</span> </> ) : i.kind === "todo" || (i.kind === "reminder" && i.line !== undefined) ? ( - i.title + <LinkedText text={i.title} from={i.path} /> ) : ( <DocLink path={i.path}>{i.title}</DocLink> )} @@ -184,9 +188,11 @@ export function UpcomingPage({ const { text } = taskDates(t.text); return ( <li key={`${t.path}:${t.line}`} className="flex items-start gap-2 rounded-lg border border-border px-3 py-2"> - <TodoBox label={text || t.text} checked={busy === `${t.path}:${t.line}`} disabled={busy === `${t.path}:${t.line}`} onChange={(d) => void mark(t, d)} /> + <TodoBox label={linksAsText(text || t.text)} checked={busy === `${t.path}:${t.line}`} disabled={busy === `${t.path}:${t.line}`} onChange={(d) => void mark(t, d)} /> <div className="min-w-0 flex-1"> - <p className="text-sm">{t.text}</p> + <p className="text-sm"> + <LinkedText text={t.text} from={t.path} /> + </p> <p className="text-xs text-muted-foreground"> in <DocLink path={t.path}>{t.title || shortPath(t.path)}</DocLink> </p> diff --git a/src/web/components/ViewParts.tsx b/src/web/components/ViewParts.tsx index fd7e6aa..0c67009 100644 --- a/src/web/components/ViewParts.tsx +++ b/src/web/components/ViewParts.tsx @@ -1,4 +1,5 @@ import type * as React from "react"; +import { linksAsText, resolveLink, textParts } from "../../core/entry.ts"; import { isSealedValue } from "../../core/sealed.ts"; import { formatAmount } from "../../core/util.ts"; import type { CurrencyTotal } from "../../core/ledger.ts"; @@ -90,6 +91,40 @@ export function DocLink({ path, children, className }: { path: string; children: ); } +const inlineLinkClass = "rounded underline underline-offset-2 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"; + +/** + * Text that may hold Markdown links, a field's value or a to-do's words, as + * somebody reads it: each link is its text, linked to the event or note it + * resolves to (from `from`, the document it is written in) or to a web page, + * and shown as plain text when it leads anywhere else. + */ +export function LinkedText({ text, from }: { text: string; from: string }) { + return ( + <> + {textParts(text).map((p, i) => { + if (p.target === undefined) return p.text; + const doc = resolveLink(from, p.target); + if (doc?.toLowerCase().endsWith(".md")) { + return ( + <DocLink key={i} path={doc} className={inlineLinkClass}> + {p.text} + </DocLink> + ); + } + if (/^https?:\/\//i.test(p.target)) { + return ( + <a key={i} href={p.target} rel="noreferrer noopener" target="_blank" className={inlineLinkClass}> + {p.text} + </a> + ); + } + return p.text; + })} + </> + ); +} + /** A front matter value as somebody reads it. A sealed value says so and nothing more. */ export function fieldText(v: unknown): string { if (v == null || v === "") return ""; @@ -101,10 +136,8 @@ export function fieldText(v: unknown): string { if (typeof o.value === "number" && typeof o.currency === "string") return formatAmount({ value: o.value, currency: o.currency }); return JSON.stringify(v); } - const s = String(v); // A Markdown link reads as its text: "[Garage](../places/garage.md)" is "Garage". - const link = /^\[([^\]]*)\]\([^)]*\)$/.exec(s.trim()); - return link ? link[1] : s; + return linksAsText(String(v)); } /** Totals per currency, each on its own: nothing is converted. */ diff --git a/src/web/store.ts b/src/web/store.ts index bf2b3d7..c49657c 100644 --- a/src/web/store.ts +++ b/src/web/store.ts @@ -130,6 +130,13 @@ export interface ViewsData { notes: LoadedEntry[]; todos: (Todo & { title: string })[]; files: FileItem[]; + /** + * Files' sidecars, read as records, as `gitroll find` reads them: their + * dated fields are on the calendar, and their links count where links do + * (what is at a place, what links to an entry). Absent from a store that + * doesn't read them. + */ + sidecars?: LoadedEntry[]; /** * Each note's revision, by path: the sha256 of the file it was read from, * as `gitroll show --json` gives it. A field edit sends it back, and is diff --git a/test/cli.test.ts b/test/cli.test.ts index faf31d0..f63e3a9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -29,7 +29,7 @@ function gitroll(args: string[], opts: { cwd?: string; input?: string; env?: Rec test("agents can discover the CLI without a Roll and complete a JSON event workflow", () => { const guide = gitroll(["help", "agent", "--json"]); assert.equal(guide.code, 0, guide.out); - assert.equal(JSON.parse(guide.out).version, 9); + assert.equal(JSON.parse(guide.out).version, 10); assert.match(gitroll(["help", "agent"]).out, /untrusted data/); const dir = tmp(); GitRoll.init(dir, { name: "Agent" }); diff --git a/test/combinations.test.ts b/test/combinations.test.ts index 5e23410..27112a3 100644 --- a/test/combinations.test.ts +++ b/test/combinations.test.ts @@ -6,8 +6,9 @@ // pinned, with a to-do and a reminder inside, closed by `gitroll close`; an // event that is an issue and has an amount; recurring notes that are pinned or // an issue; a custom collection with fields and a saved search; a file with a -// sidecar; sealed fields and blocks in an issue, a contact and a thing; and -// records that are moved after other things link to them. +// sidecar that has a date and a place; sealed fields and blocks in an issue, a +// contact and a thing; and records that are moved after other things link to +// them, into sub-folders of their collections. // // Each view is asked the same questions it would be asked about one construct // alone, and must agree with the others: nothing counted twice, nothing that @@ -22,13 +23,17 @@ import path from "node:path"; import readline from "node:readline"; import { after, before, describe, it, test } from "node:test"; import { fileURLToPath } from "node:url"; -import { addDays, linkText } from "../src/core/calendar.ts"; -import { parseEntry } from "../src/core/entry.ts"; +import { addDays, calendarItems, eventAhead, linkText } from "../src/core/calendar.ts"; +import { contacts } from "../src/core/contacts.ts"; +import { linksAsText, parseEntry, textParts, updateEntrySource } from "../src/core/entry.ts"; import { setFields } from "../src/core/fields.ts"; +import { toICalendar } from "../src/core/ical.ts"; +import { organizations } from "../src/core/organizations.ts"; +import { setSealedField } from "../src/core/sealed.ts"; import { groupFiles, linkedFrom } from "../src/core/files.ts"; import { inventoryItem } from "../src/core/inventory.ts"; import { issueOf } from "../src/core/issues.ts"; -import { moveEntry } from "../src/core/layout.ts"; +import { keepHeading, moveEntry } from "../src/core/layout.ts"; import { related } from "../src/core/relations.ts"; import { validateRepo } from "../src/core/validate.ts"; import { isoDate } from "../src/core/util.ts"; @@ -91,7 +96,79 @@ test("a field typed as a Markdown link is written as text, not refused as bad YA const out = setFields("# Garage\n", [["within", { yaml: "[Home](home.md)" }], ["org", { yaml: "[Acme](acme.md);Research" }]]); assert.match(out, /^within: "\[Home\]\(home\.md\)"$/m); assert.match(out, /^org: "\[Acme\]\(acme\.md\);Research"$/m); - assert.match(setFields("# B\n", [["authors", { yaml: "[Le Guin, Delany]" }]]), /^authors: \[ Le Guin, Delany \]$/m, "a list is still a list"); + assert.match(setFields("# B\n", [["authors", { yaml: "[Le Guin, Delany]" }]]), /^authors: \[ Le Guin, Delany \]$/m, "a list is still a list, padded as the YAML library writes one in a new file"); +}); + +test("an edit keeps the file's own flow-list style: [bike] stays [bike], [ bike ] stays [ bike ]", () => { + const tight = "---\nprojects: [bike]\ntags: [a, b]\nwith: \"[Ada](ada.md)\"\n---\n\n# Ride\n"; + const padded = "---\nprojects: [ bike ]\n---\n\n# Ride\n"; + const set = setFields(tight, [["pinned", { yaml: "true" }], ["parts", { yaml: "[chain, tyre]" }]]); + assert.match(set, /^projects: \[bike\]\ntags: \[a, b\]$/m, "set: untouched keys as they were"); + assert.match(set, /^parts: \[chain, tyre\]$/m, "and a new list the same way"); + assert.match(setFields(padded, [["parts", { yaml: "[chain]" }]]), /^projects: \[ bike \]\nparts: \[ chain \]$/m); + assert.match(updateEntrySource(tight, { title: "Ride out" }), /^projects: \[bike\]\ntags: \[a, b\]$/m, "edit and move rewrite front matter this way"); + assert.match(moveEntry(tight, ".gitroll/events/2026-10-01-ride.md", ".gitroll/events/2026/2026-10-01-ride.md"), /^projects: \[bike\]$/m); + assert.match(setSealedField(`${tight}`, "tags", ARMOR), /^projects: \[bike\]$/m, "seal"); + assert.match(updateEntrySource(padded, { title: "Ride out" }), /^projects: \[ bike \]$/m); +}); + +test("org's link part is a link: related, backlinks and check read it, and so do membership and orgPath", () => { + const acme = doc(".gitroll/notes/organizations/acme.md", "---\nalternateName: ACME\n---\n# Acme\n"); + const ada = doc(".gitroll/notes/people/ada.md", '---\norg: "[Acme](../organizations/acme.md);Research"\n---\n# Ada\n'); + const sam = doc(".gitroll/notes/people/sam.md", "---\norg: acme;Sales\n---\n# Sam\n"); + const kit = doc(".gitroll/notes/inventory/kit.md", '---\nvendor: "[Acme](../organizations/acme.md)"\n---\n# Kit\n'); + const paid = doc(".gitroll/events/2026-10-01-paid.md", '---\nvendor: "[Acme](../notes/organizations/acme.md)"\n---\n# Paid\n'); + const all = [acme, ada, sam, kit, paid]; + assert.deepEqual(related(ada, all).links.map((e) => e.path), [acme.path]); + assert.deepEqual(related(acme, all).backlinks.map((e) => e.path), [ada.path, kit.path, paid.path]); + const [org] = organizations([acme], all).organizations; + assert.deepEqual(org.members.map((m) => [m.name, m.units]), [["Ada", "Research"], ["Sam", "Sales"]]); + assert.deepEqual(org.supplied, [{ path: kit.path, title: "Kit" }], "a thing whose vendor links to it; an event's vendor is history"); + assert.deepEqual(paths(org.interactions), [paid.path]); + assert.deepEqual(contacts([ada, sam], all).contacts.map((c) => c.orgPath), [acme.path, acme.path], "a link, or a name one organization goes by"); + const twin = doc(".gitroll/notes/organizations/acme-2.md", "# ACME\n"); + assert.equal(contacts([sam], [...all, twin]).contacts[0].orgPath, null, "a name two organizations go by is neither"); + const files: Record<string, string> = { + ".gitroll/config.yaml": "template_version: 1\n", + ".gitroll/notes/people/bo.md": '---\norg: "[Gone](../organizations/gone.md);Unit"\n---\n# Bo\n', + }; + assert.deepEqual(validateRepo({ paths: Object.keys(files), read: (p) => files[p] }).map((p) => p.error), ["links to .gitroll/notes/organizations/gone.md in its front matter, which isn't in this Roll"]); +}); + +test("edit --text keeps the # Title heading unless the new text starts with one", () => { + const source = "---\ntel: 555\n---\n\n# Ada Lovelace\n\nOld words.\n"; + assert.equal(keepHeading(source, "New words."), "# Ada Lovelace\n\nNew words."); + assert.equal(keepHeading(source, "# Ada King\n\nNew words."), "# Ada King\n\nNew words.", "a heading of its own renames it"); + assert.equal(keepHeading("Just words.\n", "New words."), "New words.", "nothing to keep"); + assert.equal(keepHeading(source, "## Notes\n\nNew."), "# Ada Lovelace\n\n## Notes\n\nNew.", "a lesser heading isn't a title"); +}); + +test("an event logged today is a record of what happened, not something coming", () => { + const now = new Date(); + const today = isoDate(now); + const pad = (n: number) => String(n).padStart(2, "0"); + const at = (d: Date) => `${isoDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + const ev = (name: string, date: string) => doc(`.gitroll/events/${date.slice(0, 10)}-${name}.md`, `---\ndate: ${date}\n---\n# ${name}\n`); + const logged = ev("logged", at(new Date(now.getTime() - 60_000))); + const allDay = ev("all-day", today); + const later = ev("later", at(new Date(now.getTime() + 3_600_000))); + const tomorrow = ev("tomorrow", addDays(today, 1)); + assert.equal(eventAhead(logged.date!, today, now), false); + assert.equal(eventAhead(allDay.date!, today, now), false); + assert.equal(eventAhead(`${today}T23:59:59`, today, new Date(`${today}T23:00:00`)), true, "later today is still to come"); + assert.equal(eventAhead(tomorrow.date!, today, now), true); + const docs = [logged, allDay, tomorrow]; + assert.deepEqual(calendarItems(docs, [], { from: today, to: addDays(today, 3), today, now }).map((i) => i.title), ["tomorrow"]); + const ics = toICalendar(docs, [], { today, now }); + assert.doesNotMatch(ics, /SUMMARY:(logged|all-day)/); + assert.match(ics, /SUMMARY:tomorrow/); + if (later.date!.slice(0, 10) === today) assert.match(toICalendar([later], [], { today, now }), /SUMMARY:later/); +}); + +test("a link in a field or a to-do reads as its text", () => { + assert.equal(linksAsText("Ask [Maria](../people/maria.md) about the [rotor](https://example.com/r)"), "Ask Maria about the rotor"); + assert.equal(linksAsText("[Acme](../organizations/acme.md);Research"), "Acme;Research"); + assert.deepEqual(textParts("Call [Ada](ada.md)."), [{ text: "Call " }, { text: "Ada", target: "ada.md" }, { text: "." }]); }); test("related reads links in front matter fields as links, both ways", () => { @@ -254,7 +331,7 @@ async function build() { // A file with a sidecar that links to the thing and the place; an event's field links to it too. const receipt = path.join(tmp(), "receipt.pdf"); fs.writeFileSync(receipt, "%PDF-1.4 receipt\n"); - json(["attach", receipt, "--to", BOUGHT, "--field", "title=E-bike receipt", "--field", "about=[Commuter e-bike](../notes/inventory/commuter-e-bike.md)", "--field", "location=[Eastside Plaza](../notes/places/eastside-plaza.md)"]); + json(["attach", receipt, "--to", BOUGHT, "--field", "title=E-bike receipt", "--field", "about=[Commuter e-bike](../notes/inventory/commuter-e-bike.md)", "--field", "location=[Eastside Plaza](../notes/places/eastside-plaza.md)", "--field", `expires=${day(25)}`]); json(["set", SERVICE, "receipt=[Receipt](../files/receipt.pdf)"]); // Writes on top: a to-do and a reminder in a record, a pin on a person. @@ -283,6 +360,12 @@ describe("constructs combined in one Roll", async () => { assert.equal(json(["set", `${N}/places/garage.md`, "within=[Home](home.md)"]).changed, false, "the same text again: nothing to change"); }); + it("keeps each file's own flow-list style through set, seal and attach", () => { + assert.match(read(SERVICE), /^projects: \[bike\]$/m, "set receipt= left [bike] as written"); + assert.match(read(SQUEAL), /^projects: \[bike\]$/m, "seal --field too"); + assert.match(read(BOUGHT), /^projects: \[bike\]$/m, "and attach --to"); + }); + it("places, organizations, people and things agree on who is where", () => { const { places } = json(["places"]); const by = (name: string) => places.find((p: { name: string }) => p.name === name); @@ -292,11 +375,15 @@ describe("constructs combined in one Roll", async () => { assert.deepEqual(by("Home").notes.map((i: { title: string }) => i.title), ["River loop"], "a note linking in its text"); assert.deepEqual(by("Eastside Plaza").organizations.map((i: { title: string }) => i.title), ["Bolt Cycles"]); assert.deepEqual(paths(by("Eastside Plaza").events), [BOUGHT], "an event's location: field"); + assert.deepEqual(by("Eastside Plaza").files, [{ path: ".gitroll/files/receipt.pdf", title: "E-bike receipt" }], "a sidecar's location: field"); const [bolt] = json(["organizations"]).organizations; assert.deepEqual(bolt.location, { name: "Eastside Plaza", path: `${N}/places/eastside-plaza.md` }); assert.deepEqual(bolt.members.map((m: { name: string }) => m.name), ["Maria Lopez", "Sam Chen"], "linked, and named by alternateName"); assert.deepEqual(paths(bolt.interactions), [TOW, SERVICE, BOUGHT], "vendor: fields and links in the text alike"); + assert.deepEqual(bolt.supplied, [{ path: BIKE, title: "Commuter e-bike" }], "a thing whose vendor: links to it"); + assert.match(text(["organizations"]), /supplied 1 thing: Commuter e-bike/); + assert.equal(json(["contacts"]).contacts.find((c: { name: string }) => c.name === "Sam Chen").orgPath, BOLT, "org: bolt names it by alternateName"); const maria = json(["contacts"]).contacts.find((c: { name: string }) => c.name === "Maria Lopez"); assert.equal(maria.orgPath, BOLT); @@ -318,8 +405,16 @@ describe("constructs combined in one Roll", async () => { const counted = paths(ledger.entries); assert.equal(new Set(counted).size, counted.length, "no entry twice"); assert.deepEqual(counted.filter((p) => p.startsWith(E)).sort(), [BOUGHT, SERVICE, TOW].sort()); - // The purchase is an event's amount and the thing's price: both are counted, as SPEC.md says. - assert.deepEqual(ledger.totals, [{ currency: "USD", total: 2400 + 89 + 45 + 2400 + 18, count: 5 }]); + // The purchase is the event's amount; the e-bike's price is what it is worth, not a second purchase. + assert.ok(!counted.includes(BIKE), "the purchase, for its price, links to it: the price isn't counted again"); + // The first service links to the e-bike too, with an amount of its own; that doesn't make it the purchase. + assert.ok(read(SERVICE).includes("commuter-e-bike.md")); + assert.deepEqual(counted.filter((p) => p.startsWith(N)), [`${N}/inventory/brake-pads.md`], "a price nothing paid for is"); + assert.deepEqual(ledger.totals, [{ currency: "USD", total: 2400 + 89 + 45 + 18, count: 4 }]); + assert.deepEqual(json(["ledger", "brand:bolt"]).entries, [], "a search for the thing alone still leaves its price out"); + // The inventory still says what the thing is worth. + const bike = json(["inventory"]).items.find((i: { title: string }) => i.title === "Commuter e-bike"); + assert.deepEqual(bike.value, { value: 2400, currency: "USD" }); }); it("follows the odometer through events in any folder, the issue among them", () => { @@ -344,20 +439,39 @@ describe("constructs combined in one Roll", async () => { assert.deepEqual(paths(json(["find", "is:issue is:pinned"])), [SQUEAL]); }); - it("puts the issue's to-do, its reminder, recurring notes, the warranty and a birthday on the calendar", () => { + it("puts reminders, recurring notes, the warranty, a file's expiry and a birthday on the calendar, and not what a resolved issue left open", () => { const items = json(["upcoming", "--days", "40"]) as { kind: string; title: string; path: string; field?: string }[]; const has = (kind: string, title: string) => items.some((i) => i.kind === kind && i.title === title); - assert.ok(has("todo", "Order brake pads")); - assert.ok(has("reminder", "Order brake pads"), "⏰ on a to-do inside an issue"); assert.ok(has("reminder", "Pick up the bike"), "⏰ on a to-do inside a record"); assert.ok(has("reminder", "Commuter e-bike"), "remind: on a thing with no start"); assert.ok(has("occurrence", "Lube the chain")); assert.ok(has("reminder", "Lube the chain"), "remind: -P1D on a pinned recurring note"); assert.ok(has("occurrence", "Tires lose pressure"), "an issue that repeats"); assert.ok(items.some((i) => i.kind === "field" && i.field === "bday" && i.path === MARIA)); + assert.ok(items.some((i) => i.kind === "field" && i.field === "expires" && i.title === "E-bike receipt"), "a sidecar's dated field"); const ics = text(["calendar", "--ics"]); assert.match(ics, /RRULE:FREQ=MONTHLY/); assert.match(ics, /TRIGGER:-P1D/); + assert.match(ics, /SUMMARY:E-bike receipt: expires/); + assert.ok(json(["calendar"]).some((i: { field?: string; path: string }) => i.field === "expires" && i.path === ".gitroll/files/receipt.pdf.md")); + + // The brake squeal is resolved: its open to-do and its reminder are dealt with, though still in its file. + assert.ok(!has("todo", "Order brake pads"), "a resolved issue's to-do"); + assert.ok(!has("reminder", "Order brake pads"), "a resolved issue's ⏰"); + assert.ok(!json(["reminders"]).some((r: { path: string }) => r.path === SQUEAL)); + assert.doesNotMatch(ics, /Order brake pads/); + assert.ok(!paths(json(["todos"])).includes(SQUEAL)); + assert.ok(json(["todos", "--all"]).some((t: { path: string; done: boolean; text: string }) => t.path === SQUEAL && !t.done && t.text.startsWith("Order brake pads")), "--all still lists it"); + assert.match(read(SQUEAL), /- \[ \] Order brake pads/); + + // A recurring issue stops recurring once it is resolved, and starts again if it is reopened. + const tires = `${N}/maintenance/tire-pressure.md`; + json(["set", tires, `resolved=${today}`]); + const after = json(["upcoming", "--days", "40"]) as { kind: string; path: string }[]; + assert.ok(!after.some((i) => i.path === tires && i.kind === "occurrence")); + assert.doesNotMatch(text(["calendar", "--ics"]), /RRULE:FREQ=WEEKLY/); + json(["set", tires, "--unset", "resolved"]); + assert.ok((json(["upcoming", "--days", "40"]) as { kind: string; path: string }[]).some((i) => i.path === tires && i.kind === "occurrence")); }); it("finds by field, sorts by field, and keeps a saved search", () => { @@ -368,17 +482,30 @@ describe("constructs combined in one Roll", async () => { json(["find", "is:issue project:bike", "--save", "bike-issues"]); assert.deepEqual(paths(json(["find", "@bike-issues"])).sort(), [SQUEAL, TOW].sort()); assert.deepEqual(json(["records", "rides", "rating>=4"]).records.map((r: { title: string }) => r.title), ["River loop"]); + // A saved search works wherever a query does, beside other words and filters. + assert.deepEqual(paths(json(["find", "@bike-issues amount>10"])), [TOW]); + assert.deepEqual(paths(json(["issues", "@bike-issues"]).issues), [TOW], "only the open one"); + assert.deepEqual(paths(json(["ledger", "@bike-issues"]).entries), [TOW]); + assert.deepEqual(paths(json(["find", '"@bike-issues"'])), [], "in quotes it is text to find"); + for (const args of [["find", "@no-such-search"], ["ledger", "@no-such-search"], ["issues", "bike @no-such-search"]]) { + const r = run(args); + assert.notEqual(r.status, 0, `${args.join(" ")} is an error, not nothing found`); + assert.match(r.stderr, /no saved search called "@no-such-search"/); + } }); it("reads links in front matter as links, in related, show and files as in contacts and places", () => { const maria = json(["related", MARIA]); assert.ok(maria.backlinks.includes(BOUGHT), "with: links to her"); assert.ok(maria.backlinks.includes(SERVICE), "and so does text"); + assert.ok(maria.links.includes(BOLT), "org: [Bolt Cycles](…);Service is a link"); + assert.ok(json(["related", BOLT]).backlinks.includes(MARIA), "both ways"); const bike = json(["related", BIKE]); assert.ok(bike.links.includes(`${N}/places/garage.md`), "location:"); assert.ok(bike.links.includes(BOLT), "vendor:"); assert.ok(bike.backlinks.includes(SQUEAL), "about: on the issue"); assert.ok(bike.backlinks.includes(`${N}/maintenance/chain-lube.md`)); + assert.ok(bike.backlinks.includes(".gitroll/files/receipt.pdf.md"), "a sidecar's about: links to it too"); assert.deepEqual(bike.missing, []); assert.match(text(["show", MARIA]), new RegExp(`linked from: ${path.basename(BOUGHT, ".md")}`)); @@ -436,6 +563,9 @@ describe("constructs combined in one Roll", async () => { ["places", {}, ["places"]], ["related", { file: MARIA }, ["related", MARIA]], ["records", { collection: "people" }, ["records", "people"]], + ["issues", { query: ["@bike-issues"] }, ["issues", "@bike-issues"]], + ["ledger", { query: "@bike-issues tow" }, ["ledger", "@bike-issues", "tow"]], + ["pin", { file: MARIA }, ["pin", MARIA]], ]; try { for (const [tool, args, argv] of cases) { @@ -470,18 +600,26 @@ describe("constructs combined in one Roll", async () => { assert.equal(json(["issues", "--all"]).issues.find((i: { path: string }) => i.path === squeal).status, "resolved"); assert.match(read(squeal), /^about: "\[Commuter e-bike\]\(\.\.\/notes\/inventory\/commuter-e-bike\.md\)"$/m); // The organization is renamed: Maria's org (with its unit), the vendor: fields and the text follow. - json(["move", BOLT, `${N}/organizations/bolt.md`]); + assert.equal(json(["move", BOLT, `${N}/organizations/bolt.md`]).relinked, 5, "the text, three vendor: fields and Maria's org"); assert.match(read(MARIA), /^org: "\[Bolt Cycles\]\(\.\.\/organizations\/bolt\.md\);Service"$/m); assert.match(read(TOW), /^vendor: "\[Bolt Cycles\]\(\.\.\/notes\/organizations\/bolt\.md\)"$/m); const [bolt] = json(["organizations"]).organizations; assert.equal(bolt.members.length, 2); assert.equal(bolt.interactions.length, 3); - // The garage moves to another folder: the things in it keep their place. + // The garage moves into a sub-folder of places: the things in it keep their place, and it stays in the tree. json(["move", `${N}/places/garage.md`, `${N}/places/home/garage.md`]); assert.deepEqual(json(["inventory"]).items.map((i: { location: { trail: string[] } }) => i.location.trail), [["Springfield", "Home", "Garage"], ["Springfield", "Home", "Garage"]]); - // The thing moves: the sidecar's about: follows it. - json(["move", BIKE, `${N}/inventory/e-bike.md`]); - assert.match(read(".gitroll/files/receipt.pdf.md"), /^about: "\[Commuter e-bike\]\(\.\.\/notes\/inventory\/e-bike\.md\)"$/m); + const garage = json(["places"]).places.find((p: { name: string }) => p.name === "Garage"); + assert.equal(garage?.parent, `${N}/places/home.md`, "a place in places/home/ is still within Home"); + assert.equal(garage.items.length, 2); + // The thing moves into a sub-folder of inventory: the sidecar's about: follows it, and every view still has it. + const ebike = `${N}/inventory/bikes/e-bike.md`; + json(["move", BIKE, ebike]); + assert.match(read(".gitroll/files/receipt.pdf.md"), /^about: "\[Commuter e-bike\]\(\.\.\/notes\/inventory\/bikes\/e-bike\.md\)"$/m); + assert.ok(paths(json(["inventory"]).items).includes(ebike), "inventory reads its sub-folders"); + assert.ok(paths(json(["records", "inventory"]).records).includes(ebike), "and so do records"); + assert.equal(json(["records"]).find((c: { name: string }) => c.name === "inventory").records, 2); + assert.ok(paths(json(["places"]).places.find((p: { name: string }) => p.name === "Garage").items).includes(ebike)); assert.match(text(["check"]), /looks good/); assert.equal(json(["status"]).uncommitted, 0, "every move committed what it changed"); }); @@ -495,6 +633,68 @@ describe("constructs combined in one Roll", async () => { write(TOW, tow); assert.match(text(["check"]), /looks good/); }); + + it("shows a link in a field or a to-do as its text, and keeps the value in JSON", () => { + // An earlier step moved the e-bike into inventory/bikes/. + const bike = `${N}/inventory/bikes/e-bike.md`; + const table = text(["records", "inventory"], NO_KEY); + assert.match(table, /Bolt Cycles/); + assert.doesNotMatch(table, /\]\(/, "no [text](link) in the table"); + assert.match(text(["show", bike], NO_KEY), /^ {2}vendor: Bolt Cycles$/m); + // The squeal is resolved by now, so its to-do is only listed with --all. + assert.match(text(["todos", "--all"], NO_KEY), /Ask Maria about the rotor/); + assert.match(json(["records", "inventory"], NO_KEY).records.find((r: { path: string }) => r.path === bike).fields.vendor, /^\[Bolt Cycles\]\(/); + }); + + it("returns a write's entry with its sealed fields as sealed, as a read does", () => { + const writes: string[][] = [["set", MARIA, "nickname=Mari"], ["pin", MARIA], ["edit", MARIA, "--text", "Met at the shop."], ["todo", "Check the rotor", "--to", MARIA]]; + for (const w of writes) { + const out = run([...w, "--json"], NO_KEY); + assert.equal(out.status, 0, out.stderr); + assert.doesNotMatch(out.stdout, /BEGIN AGE/, w.join(" ")); + assert.deepEqual(JSON.parse(out.stdout).entry.meta.tel, { sealed: true }, w.join(" ")); + } + const line = read(MARIA).split("\n").findIndex((l) => l.includes("Check the rotor")) + 1; + const done = json(["done", `${MARIA}:${line}`], NO_KEY); + assert.deepEqual([done.entry.meta.gate_code, done.entry.sealed.length], [{ sealed: true }, 2]); + assert.match(read(MARIA), /^tel: \|\n {2}-----BEGIN AGE ENCRYPTED FILE-----$/m, "the file keeps its ciphertext"); + // edit --text kept the title: Maria is still Maria Lopez. + assert.match(read(MARIA), /^# Maria Lopez\n\nMet at the shop\.\n/m); + assert.ok(json(["contacts"]).contacts.some((c: { name: string }) => c.name === "Maria Lopez")); + }); + + it("writes a sealed tel to vCard opened with a key, and leaves it out, saying so, without one", () => { + assert.match(text(["contacts", "--vcf"]), /\r\nTEL:\+1 555 0101\r\n/, "the key holder's own address book"); + const without = run(["contacts", "--vcf"], NO_KEY); + assert.doesNotMatch(without.stdout, /TEL|BEGIN AGE/); + assert.match(without.stderr, /Left out, because no key on this computer opens it: tel in notes\/people\/maria-lopez\.md/); + assert.deepEqual(json(["contacts", "--vcf"], NO_KEY).sealed, [{ path: MARIA, field: "tel" }], "gate_code isn't a vCard property"); + }); + + it("exports sealed fields to CSV as ciphertext, says so, and imports them back sealed", () => { + const out = run(["records", "people", "--csv"], NO_KEY); + assert.match(out.stdout, /BEGIN AGE ENCRYPTED FILE/); + assert.match(out.stderr, /Sealed fields tel, gate_code are written as ciphertext/); + assert.deepEqual(json(["records", "people", "--csv"], NO_KEY).sealed, ["tel", "gate_code"]); + const csv = path.join(tmp(), "people.csv"); + fs.writeFileSync(csv, out.stdout); + json(["import", "csv", "people-copy", csv]); + const copy = json(["show", `${N}/people-copy/maria-lopez.md`, "--unsealed"]); + assert.deepEqual([copy.meta.gate_code, copy.meta.tel], [{ sealed: true, text: "4417" }, { sealed: true, text: "+1 555 0101" }]); + }); + + it("leaves an event logged today off the calendar: it happened, it isn't coming", () => { + const logged = json(["log", "Rode to work"]).entry.path; + const later = json(["log", "Bike club ride", "--at", day(2)]).entry.path; + const items = (args: string[]) => paths(json(args) as { path: string }[]); + for (const view of [["upcoming"], ["calendar"]]) { + assert.ok(!items(view).includes(logged), view.join(" ")); + assert.ok(items(view).includes(later), view.join(" ")); + } + const ics = text(["calendar", "--ics"]); + assert.doesNotMatch(ics, /SUMMARY:Rode to work/); + assert.match(ics, /SUMMARY:Bike club ride/); + }); }); // The same Roll in the browser app: every page loads without an error, an @@ -516,6 +716,9 @@ describe("constructs combined, in the browser app", { skip: !fs.existsSync(path. if (!browser) return; await build(); process.env.GITROLL_IDENTITY = NO_KEY.GITROLL_IDENTITY; + json(["find", "is:issue project:bike", "--save", "bike-issues"]); + // An open to-do with a link, on a note that is not a resolved issue, for Upcoming to show. + json(["todo", "Ask [Maria](../people/maria-lopez.md) about the chain", "--to", `${N}/maintenance/chain-lube.md`]); server = await serve(new GitRoll(root), { port: 0, webDir: WEB_DIR, token: "test-token" }); }, { timeout: 180_000 }); after(() => { @@ -537,7 +740,7 @@ describe("constructs combined, in the browser app", { skip: !fs.existsSync(path. it("loads every page without an error or anything sealed", { skip }, async () => { const expect: Record<string, string> = { - "": "Brake squeal on the e-bike", upcoming: "Order brake pads", ledger: "4,952.00 USD", series: "1,602", inventory: "Springfield › Home › Garage", + "": "Brake squeal on the e-bike", upcoming: "Lube the chain", ledger: "2,552.00 USD", series: "1,602", inventory: "Springfield › Home › Garage", contacts: "Maria Lopez", organizations: "Bolt Cycles", places: "Eastside Plaza", issues: "Tow fee disputed", files: "E-bike receipt", notes: "maintenance", }; for (const [hash, words] of Object.entries(expect)) { @@ -562,6 +765,53 @@ describe("constructs combined, in the browser app", { skip: !fs.existsSync(path. await page.close(); }); + it("shows a resolved issue's status, not its issue: field, and leaves its to-do off Upcoming", { skip }, async () => { + const { page, errors } = await load(`entry/${encodeURIComponent(SQUEAL)}`); + await page.getByText("Resolved issue").waitFor(); + const shown = await page.locator("#main").innerText(); + assert.doesNotMatch(shown, /^issue\s+open$/m, "no raw issue: open row"); + await page.close(); + + const timeline = await load(""); + const card = timeline.page.locator("article", { hasText: "Brake squeal on the e-bike" }).first(); + await card.getByText("Resolved issue").waitFor(); + assert.doesNotMatch(await card.innerText(), /^issue\s+open$/m); + await timeline.page.close(); + + const upcoming = await load("upcoming"); + await upcoming.page.getByText("Lube the chain").first().waitFor(); + await upcoming.page.getByText("E-bike receipt").first().waitFor(); + assert.doesNotMatch(await upcoming.page.locator("#main").innerText(), /Order brake pads/); + assert.deepEqual([...errors, ...timeline.errors, ...upcoming.errors], []); + await upcoming.page.close(); + }); + + it("reads a saved search in a page's search box, and says when there is none by that name", { skip }, async () => { + const { page, errors } = await load("ledger"); + await page.getByText("2,552.00 USD").first().waitFor(); + await page.getByLabel("Filter").fill("@bike-issues"); + await page.getByText("1 entry").first().waitFor(); + await page.getByLabel("Filter").fill("@no-such-search"); + await page.getByRole("alert").filter({ hasText: 'no saved search called "@no-such-search"' }).waitFor(); + assert.deepEqual(errors, []); + await page.close(); + }); + + it("shows a link in a field or a to-do as its text, linked", { skip }, async () => { + const thing = await load(`entry/${encodeURIComponent(BIKE)}`); + await thing.page.locator("dl").first().getByRole("link", { name: "Bolt Cycles" }).waitFor(); + assert.doesNotMatch(await thing.page.locator("dl").first().innerText(), /\]\(/); + await thing.page.close(); + const upcoming = await load("upcoming"); + await upcoming.page.getByText("Ask Maria about the chain").waitFor(); + assert.equal(await upcoming.page.getByRole("link", { name: "Maria", exact: true }).count(), 1); + await upcoming.page.close(); + const orgs = await load("organizations"); + await orgs.page.getByRole("cell", { name: "Commuter e-bike" }).waitFor(); + assert.deepEqual([...thing.errors, ...upcoming.errors, ...orgs.errors], []); + await orgs.page.close(); + }); + it("shows a sealed field as [sealed] on a record's page", { skip }, async () => { const { page } = await load(`entry/${encodeURIComponent(MARIA)}`); await page.getByRole("heading", { name: "Maria Lopez" }).waitFor(); diff --git a/test/entities.test.ts b/test/entities.test.ts index 56bc4ba..5138f29 100644 --- a/test/entities.test.ts +++ b/test/entities.test.ts @@ -16,6 +16,8 @@ import { inventory } from "../src/core/inventory.ts"; import { addressText, organizations } from "../src/core/organizations.ts"; import { coordinatesOf, parseGeoUri, places } from "../src/core/places.ts"; import { frontMatterLinks } from "../src/core/relations.ts"; +import { recordsIn } from "../src/core/fields.ts"; +import { parseSidecar } from "../src/core/files.ts"; import * as core from "../src/core/index.ts"; import { GitRoll } from "../src/node/repo.ts"; import { tmp } from "./helpers.ts"; @@ -90,7 +92,7 @@ test("a contact's org may be a link to an organization: read as its name, and wr assert.deepEqual(orgReference(grace, "Acme;Research; Lab"), { name: "Acme", units: "Research; Lab", path: null }); assert.deepEqual(orgReference(grace, "[Odd] name"), { name: "[Odd] name", units: "", path: null }); const people = contacts([ada, grace], allDocs).contacts; - assert.deepEqual(people.map((c) => [c.org, c.orgPath]), [["Acme, Research", ".gitroll/notes/organizations/acme.md"], ["acme corporation ltd", null]]); + assert.deepEqual(people.map((c) => [c.org, c.orgPath]), [["Acme, Research", ".gitroll/notes/organizations/acme.md"], ["acme corporation ltd", ".gitroll/notes/organizations/acme.md"]], "a link, or a name exactly one organization goes by"); assert.match(toVCard([ada]), /\r\nORG:Acme;Research\r\n/); }); @@ -190,3 +192,17 @@ test("gitroll organizations and gitroll places, with --json, a query and --colle const schema = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", cli, "schema", "places"], { encoding: "utf8", cwd: tmp(), timeout: 10_000 }); assert.deepEqual(Object.keys(JSON.parse(schema.stdout).commands[0].options), ["repo", "roll", "collection"]); }); + +test("places: sub-folders of places and people count as theirs, and a file is where its sidecar says", () => { + const attic = doc(".gitroll/notes/places/house/attic.md", 'within: "[House](../house.md)"', "# Attic"); + const cousin = doc(".gitroll/notes/people/family/cousin.md", 'location: "[Attic](../../places/house/attic.md)"', "# Cousin"); + const deeds = parseSidecar(".gitroll/files/deeds.pdf.md", '---\ntitle: House deeds\nlocation: "[Attic](../notes/places/house/attic.md)"\nexpires: 2030-01-01\n---\n', ".gitroll/files/deeds.pdf"); + const records = recordsIn([house, attic, cousin], "places"); + assert.deepEqual(records.map((r) => r.title), ["House", "Attic"]); + const view = places(records, [house, attic, cousin, deeds]); + const at = view.places.find((p) => p.name === "Attic")!; + assert.deepEqual([at.parent, at.depth], [house.path, 1], "a place in places/house/ is in the tree"); + assert.deepEqual(at.people.map((p) => p.title), ["Cousin"], "someone in people/family/ is a person"); + assert.deepEqual(at.files, [{ path: ".gitroll/files/deeds.pdf", title: "House deeds" }]); + assert.deepEqual(at.items, []); +}); diff --git a/test/fields.test.ts b/test/fields.test.ts index 15d57c1..8dfb5c3 100644 --- a/test/fields.test.ts +++ b/test/fields.test.ts @@ -12,8 +12,8 @@ import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { parseEntry } from "../src/core/entry.ts"; -import { collections, columnsOf, compareValue, fieldType, fieldYaml, matchesValue, parseSort, recordsIn, setFields, sortByFields } from "../src/core/fields.ts"; -import { parseQuery, searchEntries, serialize, tokenize } from "../src/core/search.ts"; +import { collections, columnsOf, compareValue, fieldType, fieldYaml, inCollection, matchesValue, parseSort, recordsIn, setFields, sortByFields } from "../src/core/fields.ts"; +import { expandSavedSearches, parseQuery, searchEntries, serialize, tokenize } from "../src/core/search.ts"; import { GitRoll } from "../src/node/repo.ts"; import { tmp } from "./helpers.ts"; @@ -264,3 +264,49 @@ test("a field's value reads back as the YAML that writes it again", () => { assert.equal(fieldYaml(null), ""); assert.equal(fieldYaml({ value: 12, currency: "USD" }), null, "a mapping isn't one line of YAML to edit"); }); + +test("a collection holds the records in the folders under it too, and saved searches expand anywhere in a query", () => { + const notes = [ + doc(".gitroll/notes/inventory/drill.md", "price: 80", "# Drill"), + doc(".gitroll/notes/inventory/tools/saw.md", "price: 20", "# Saw"), + doc(".gitroll/notes/inventory/tools/README.md", "kind: about", "# Tools"), + doc(".gitroll/notes/inventory-old/lamp.md", "price: 5", "# Lamp"), + ]; + assert.deepEqual(recordsIn(notes, "Inventory").map((r) => r.title), ["Drill", "Saw"], "a sub-folder's records, not its README, nor a folder that only starts the same"); + assert.deepEqual(recordsIn(notes, "inventory/tools/").map((r) => r.title), ["Saw"]); + const counts = Object.fromEntries(collections(notes).map((c) => [c.name, c.records])); + assert.deepEqual(counts, { inventory: 2, "inventory/tools": 1, "inventory-old": 1 }, "a collection counts what `records <name>` lists"); + assert.ok(inCollection(".gitroll/notes/People/family/ada.md", "people")); + assert.ok(!inCollection(".gitroll/notes/people.md", "people")); + + const saved: Record<string, string> = { unpaid: "tag:unpaid", big: "@unpaid amount>100", loop: "@loop" }; + const lookup = (name: string) => saved[name]; + assert.equal(expandSavedSearches("@unpaid", lookup), "tag:unpaid"); + assert.equal(expandSavedSearches("plumber @big", lookup), "plumber tag:unpaid amount>100", "beside other terms, and one saved search may use another"); + assert.equal(expandSavedSearches('"@unpaid" a@b.c', lookup), '"@unpaid" a@b.c', "in quotes, or inside a word, it is text"); + assert.throws(() => expandSavedSearches("@nope tag:x", lookup), /no saved search called "@nope"/); + assert.throws(() => expandSavedSearches("@loop", lookup), /uses itself/); +}); + +test("a saved search works in every command that takes a query, and one that isn't saved is an error", () => { + const roll = GitRoll.init(tmp(), { name: "Saved" }); + roll.save({ text: "Paid the plumber #unpaid", amount: { value: 240, currency: "USD" } }); + roll.save({ text: "Coffee #unpaid", amount: { value: 4, currency: "USD" } }); + fs.mkdirSync(path.join(roll.root, ".gitroll/notes/books/sci-fi"), { recursive: true }); + fs.writeFileSync(path.join(roll.root, ".gitroll/notes/books/sci-fi/dune.md"), "---\nrating: 5\n---\n# Dune\n"); + const run = (args: string[]) => spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", cli, ...args, "-C", roll.root], { encoding: "utf8", cwd: tmp(), timeout: 20_000 }); + const json = (args: string[]) => { + const r = run([...args, "--json"]); + assert.equal(r.status, 0, r.stderr); + return JSON.parse(r.stdout); + }; + json(["find", "tag:unpaid", "--save", "unpaid"]); + json(["find", "rating>=4", "--save", "good"]); + assert.equal(json(["find", "@unpaid amount>100"]).length, 1); + assert.deepEqual(json(["ledger", "@unpaid"]).totals, [{ currency: "USD", total: 244, count: 2 }]); + assert.equal(json(["records", "books", "@good"]).records.length, 1, "a record in a sub-folder of the collection, found by a saved search"); + assert.equal(json(["notes", "@good"]).length, 1); + const missing = run(["ledger", "@nope"]); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /no saved search called "@nope"/); +}); diff --git a/test/issues.test.ts b/test/issues.test.ts index 8c3c177..392d0a2 100644 --- a/test/issues.test.ts +++ b/test/issues.test.ts @@ -11,7 +11,9 @@ import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { parseEntry } from "../src/core/entry.ts"; -import { isIssue, issueOf, issues, resolvesLinks, resolvesValue } from "../src/core/issues.ts"; +import { isIssue, issueOf, issues, resolvedIssues, resolvesLinks, resolvesValue, withoutResolved } from "../src/core/issues.ts"; +import { upcomingWithReminders } from "../src/core/reminders.ts"; +import { toICalendar } from "../src/core/ical.ts"; import { isPinned, pinnedFirst } from "../src/core/pins.ts"; import { SearchIndex } from "../src/core/search.ts"; import * as core from "../src/core/index.ts"; @@ -183,3 +185,23 @@ test("gitroll issues and gitroll close: lists open issues, and resolves one by l assert.deepEqual(Object.keys(JSON.parse(schema.stdout).commands[0].options), ["repo", "roll", "note", "at"]); assert.equal(run(roll, ["issues", "--note", "x", "--json"]).status, 1, "only --all"); }); + +test("a resolved issue's to-dos, reminders and repeats leave the calendar; its file keeps them", () => { + const tires = doc(".gitroll/notes/bike/tires.md", "issue: open\nstart: 2026-10-01\nrrule: FREQ=WEEKLY\nremind: -PT1H", "# Tires lose pressure\n\n- [ ] Buy a pump 📅 2026-10-09 ⏰ 2026-10-08 18:00"); + const settled = doc(".gitroll/notes/bike/tires.md", "issue: open\nresolved: 2026-10-06\nstart: 2026-10-01\nrrule: FREQ=WEEKLY\nremind: -PT1H", "# Tires lose pressure\n\n- [ ] Buy a pump 📅 2026-10-09 ⏰ 2026-10-08 18:00"); + const chain = doc(".gitroll/notes/bike/chain.md", "start: 2026-10-01\nrrule: FREQ=WEEKLY", "# Lube the chain"); + const todo = { path: tires.path, line: 9, text: "Buy a pump 📅 2026-10-09 ⏰ 2026-10-08 18:00", done: false }; + const now = new Date("2026-10-07T12:00:00"); + const items = (docs: typeof tires[], todos: (typeof todo)[]) => { + const kept = withoutResolved(docs, todos); + return upcomingWithReminders(kept.docs, kept.todos, "2026-10-07", 14, now).map((i) => `${i.kind} ${i.title}`); + }; + const open = items([tires, chain], [todo]); + assert.ok(open.includes("occurrence Tires lose pressure") && open.includes("todo Buy a pump") && open.includes("reminder Tires lose pressure")); + const done = items([settled, chain], [todo]); + assert.deepEqual(done.filter((i) => /Tires|pump/.test(i)), [], "nothing from the resolved issue"); + assert.ok(done.includes("occurrence Lube the chain"), "what isn't an issue is untouched"); + assert.deepEqual([...resolvedIssues([settled, chain, clunk, fixed])].sort(), [clunk.path, settled.path].sort()); + assert.equal(withoutResolved([settled], [todo]).docs[0].meta.issue, "open", "the rest of its front matter is as it was"); + assert.doesNotMatch(toICalendar(withoutResolved([settled], [todo]).docs, [], { today: "2026-10-07" }), /RRULE|VALARM/); +}); diff --git a/test/ledger.test.ts b/test/ledger.test.ts index ade756a..9d9859d 100644 --- a/test/ledger.test.ts +++ b/test/ledger.test.ts @@ -104,3 +104,41 @@ test("gitroll ledger with a query, --by and --hledger", () => { assert.match(journal.stdout, /2026-09-15 AC serviced\n.*\n {4}expenses:house {2}325 USD\n {4}assets:unknown/); assert.equal(run(roll, ["ledger", "--by", "a b", "--json"]).status, 1); }); + +test("a record's price isn't counted again when the event that bought it links to it: that event is the purchase", () => { + const bike = doc(".gitroll/notes/inventory/bike.md", "price: 2400", "# Bike"); + const lamp = doc(".gitroll/notes/inventory/lamp.md", "price: 40", "# Lamp"); + const saw = doc(".gitroll/notes/inventory/tools/saw.md", "price: 25", "# Saw"); + const bought = doc(".gitroll/events/2026-09-01-bought.md", "amount: 2400", "# Bought the bike\n\nThe [bike](../notes/inventory/bike.md)."); + const sawPaid = doc(".gitroll/events/2026-09-02-saw.md", 'amount: 25\nfor: "[Saw](../notes/inventory/tools/saw.md)"', "# Saw"); + const mention = doc(".gitroll/events/2026-09-03-lamp.md", "tags: [home]", "# Moved the [lamp](../notes/inventory/lamp.md)"); + const all = [bike, lamp, saw, bought, sawPaid, mention]; + const view = ledger(all); + assert.deepEqual(view.entries.map((e) => e.path).sort(), [bought.path, sawPaid.path, lamp.path].sort(), "a text link and a front matter link alike; a link with no amount isn't a purchase"); + assert.deepEqual(view.totals, [{ currency: "USD", total: 2465, count: 3 }]); + assert.deepEqual(ledger([bike, lamp], undefined, all).entries.map((e) => e.path), [lamp.path], "a search of the records still knows what paid for them"); + assert.deepEqual(ledger([bike]).entries.map((e) => e.path), [bike.path], "with nothing that paid for it, a price counts as before"); +}); + +test("only the purchase leaves a price out: an event on the purchaseDate, or for the price when there is none; a service never does", () => { + const link = "\n\nThe [bike](../notes/inventory/bike.md)."; + const service = doc(".gitroll/events/2026-10-01-service.md", "amount: 89", `# Serviced${link}`); + const priced = doc(".gitroll/notes/inventory/bike.md", "price: 2400", "# Bike"); + const dated = doc(".gitroll/notes/inventory/bike.md", "price: 2400\npurchaseDate: 2026-09-01", "# Bike"); + const counted = (docs: ReturnType<typeof doc>[]) => ledger(docs).entries.map((e) => e.path).sort(); + + assert.deepEqual(counted([priced, service]), [priced.path, service.path].sort(), "a service linking to the bike doesn't hide its price"); + assert.deepEqual(counted([dated, service]), [dated.path, service.path].sort()); + + // By amount, when the thing has no purchaseDate: the same value in the same currency. + const bought = doc(".gitroll/events/2026-09-01-bought.md", "amount: 2400", `# Bought${link}`); + assert.deepEqual(counted([priced, service, bought]), [bought.path, service.path].sort()); + const euros = doc(".gitroll/events/2026-09-01-bought.md", "amount: 2400\ncurrency: EUR", `# Bought${link}`); + assert.ok(counted([priced, euros]).includes(priced.path), "not in another currency"); + + // By date, when it has one: whatever the amount (a deposit, a discount). + const discounted = doc(".gitroll/events/2026-09-01-bought.md", "amount: 2100", `# Bought${link}`); + assert.deepEqual(counted([dated, discounted]), [discounted.path]); + const sameAmountLater = doc(".gitroll/events/2026-09-20-bought.md", "amount: 2400", `# Paid${link}`); + assert.ok(counted([dated, sameAmountLater]).includes(dated.path), "with a purchaseDate, only an event on that day is the purchase"); +});