diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..337062b9 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6390,3 +6390,51 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40 mapped, 1 correctly absent, run by bento-team-slides. Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8 + + +## 2026-09-10 — spaces view filters: a FLAT condition list, and unknown operators show MORE + +**Decision.** `bento/spaces` `ViewFilter` gains two keys and no more: `where`, a +FLAT array of `{key, op, v?}` clauses, and `any`, a boolean that ORs them +instead of ANDing them. Eleven operators — `eq` `ne` `gt` `gte` `lt` `lte` +`contains` `notContains` `empty` `notEmpty` `in` — plus five relative date +windows for `in` (`today` `week` `month` `past` `future`). The engine is +`spaces/src/query.ts`; `fields.ts` calls into it and is otherwise unchanged. + +**Why flat, and not a tree.** A nested group needs a UI that can show, build and +unbuild a tree, and the filter popover is a bottom sheet on a phone. "Due this +week AND not tagged draft" and "urgent OR overdue" are the shapes people +actually ask for and both are flat. Nesting stays available later as another +additive key, where widening a flat list into a tree afterwards would not be. + +**Why `any` reaches only `where`.** The result is `open AND is AND (where, +combined by all-or-any)`. Letting `any` reach `is` or `open` would change what a +file already on somebody's disk means, which no key may ever do. + +**Unknown operators show MORE rows, never fewer, and say so.** An operator this +build cannot evaluate is reported by `unknownFilterOps` (the sibling of +`unknownFilterKeys`) and treated as no constraint under AND — and as PASSING +under `any`, because skipping a clause in an OR leaves fewer ways through and +would hide rows for a rule nobody can read. Both directions are the same rule: +a superset with a banner over it, never a silently wrong set. This follows the +precedents already in `fields.ts` — `isOpenPhase` counts an unknown status as +open, an empty `is` list is no constraint. An OLDER build meeting `where` does +the same one level up: unknown key, superset, existing banner. + +**Dates never construct a Date from a string.** A `date` field holds +`YYYY-MM-DD`, whose string order is its chronological order in every timezone, +so comparison is string comparison. Windows are built from journal.ts's +`todayISO`/`stepDay` — calendar arithmetic in the reader's own zone. +`new Date('2026-01-01')` is UTC midnight by spec and therefore the previous day +for half the world; it appears nowhere in query.ts. The week START is read from +`Intl.Locale.weekInfo` (Monday fallback) and is VIEWER-scoped, never stored: +the same file answers "this week" as Mon–Sun in Berlin and Sun–Sat in Chicago, +the same rule the app already follows for language and date formatting. + +**No `eval`, no `new Function`** — a filter comes out of a mailed file exactly +like block html, and calc.ts's argument carries over unchanged. Fixed operator +table, typed values, returns a boolean. + +Coverage: 69 behavioural assertions in `scripts/test-spaces-model.ts` asserting +on ROWS, including a compatibility block proving every pre-`where` filter shape +still selects exactly its old rows; 13 sabotages, all caught. diff --git a/docs/spaces-agents.md b/docs/spaces-agents.md index 8d151b43..403a1ce2 100644 --- a/docs/spaces-agents.md +++ b/docs/spaces-agents.md @@ -79,7 +79,7 @@ unique ids the first time. | `pagelink` | `page` | a card linking to another page | | `link` | `url`, `title`, `desc`, `site`, `icon`, `image`, `html` | a card linking OUT of the space — see **Link cards** | | `prop` | `key`, `value`, `html` | one field value — see **The issue tracker** | -| `view` | `layout`, `groupBy`, `html` | a board or list of this space's issues | +| `view` | `layout`, `groupBy`, `sort`, `source`, `filter`, `html` | a board or list of this space's issues | `type` is a **string**, not a closed set: an unknown type survives a round trip and renders its `html` as a fallback. Properties are **flat on the block** — @@ -266,6 +266,49 @@ list**: `{ "type": "view", "layout": "board", "groupBy": "status", "html": "Issues by status" }`. Put it on a page of its own — a page carrying a view is laid out wide. +### Narrowing a view + +`filter` is optional and every key inside it is too. **Absent means everything**, +and so does an absent key — a view with no `filter` shows every row, forever. +Write no `filter` key at all rather than an empty object. + +```json +{ "type": "view", "layout": "table", "filter": { + "open": true, + "is": { "labels": ["ui"] }, + "where": [ + { "key": "due", "op": "in", "v": "past" }, + { "key": ":title", "op": "contains", "v": "onboarding" } + ] +} } +``` + +- `open` — only rows whose status phase is neither `done` nor `cancelled`. +- `is` — `{ fieldKey: [values] }`, membership; a row passes a key if it holds + any of the listed values. **An empty list is no constraint**, not "nothing + passes". +- `where` — a flat list of conditions, ANDed. Set `"any": true` alongside it to + OR them instead; `any` reaches `where` only, and `open`/`is` always AND. +- Each condition is `{ key, op, v? }`. `key` is a field key, or `":title"` for + the page title. + +| `op` | means | `v` | +|---|---|---| +| `eq` / `ne` | exact match on the STORED value; membership for a list-valued field | string or number | +| `gt` `gte` `lt` `lte` | numeric for a `number` field, otherwise text — and a `date` field's `YYYY-MM-DD` sorts chronologically as text | string or number | +| `contains` / `notContains` | case-insensitive substring of the READABLE value (a select's label, a labels list joined) | string | +| `empty` / `notEmpty` | the value is unset — absent, `""` or `[]` | — omit `v` | +| `in` | a date within a window resolved against the READER's today | `"today"` `"week"` `"month"` `"past"` `"future"` | + +Two conditions on one number make a range. `"past"` on a due date is "overdue". +Relative windows are stored as the WORD and resolved when the view is drawn, in +the reader's own timezone and week — a stored `{"op":"lt","v":"2026-09-10"}` +would be right on the day you wrote it and wrong every day after. + +An operator a build cannot evaluate is **not applied and reported**, so the view +shows a superset with a banner over it rather than the wrong rows. That is the +same trade the format makes everywhere: never silently narrow. + **Not in this format, deliberately**: teams, per-user permissions, notifications, automation. The file is the team boundary and the capability. diff --git a/scripts/test-spaces-model.ts b/scripts/test-spaces-model.ts index a61883d3..7b2bf784 100644 --- a/scripts/test-spaces-model.ts +++ b/scripts/test-spaces-model.ts @@ -43,6 +43,9 @@ import { sortRows, unknownSortKeys, sortDirOf, cycleSort, type IssueRow, VIEW_LAYOUTS, layoutOf, nextLayout, } from '../spaces/src/fields.ts' +import { + unknownFilterOps, clauseCount, clauseSummary, windowRange, opsFor, +} from '../spaces/src/query.ts' import { inlineHtml, parseNote, planImport } from '../spaces/src/markdown.ts' import { canonicalMarks, applyMark, clearMarks, markActive, linkAt, linkAttrs, htmlToMd, @@ -1969,6 +1972,206 @@ function fsTable(f: string): string { 'and a field it does not carry has none — the drop creates one through propBlock') } +// ---- a view can ask a REAL QUESTION --------------------------------------- +// `filter` had two keys — a phase flag and value membership — so a view could +// ask "which of these values" and nothing else. `where` is the third, and +// everything here is a thing that fails silently: a condition this build cannot +// read must show MORE rows and say so rather than fewer and stay quiet; a +// relative date must mean the reader's own day and not UTC's; a clause out of a +// mailed file must not throw out of a render; and — the one that matters most — +// EVERY FILTER WRITTEN BEFORE THIS must select exactly the rows it always did. +{ + const F = [ + { key: 'status', label: 'Status', vt: 'select', options: [ + { id: 'todo', label: 'Todo', group: 'unstarted' }, + { id: 'done', label: 'Shipped', group: 'done' }, + ] }, + { key: 'year', label: 'Year', vt: 'number' }, + { key: 'due', label: 'Due', vt: 'date' }, + { key: 'labels', label: 'Labels', vt: 'labels' }, + { key: 'note', label: 'Note', vt: 'text' }, + ] as unknown as FieldSpec[] + + const issue = (id: string, title: string, v: Record): Page => ({ + id, title, + blocks: Object.entries(v).map(([key, value], i) => ({ + id: `${id}-${i}`, type: 'prop', key, value, + html: `${key}: ${String(value)}`, + })) as Block[], + }) + + const doc = { + format: FORMAT, version: 1, docId: 'q', title: 'Q', theme: {}, fields: F, + pages: [ + issue('a', 'Onboarding rewrite', { status: 'todo', year: 2019, due: '2026-01-05', labels: ['draft'], note: 'alpha' }), + issue('b', 'Ship the thing', { status: 'todo', year: 2021, due: '2026-01-10', labels: ['bug', 'ui'], note: '' }), + issue('c', 'Old business', { status: 'done', year: 2024, due: '2025-12-31', labels: [], note: 'beta' }), + issue('d', 'No dates here', { status: 'todo', year: 2030, labels: ['draft', 'ui'] }), + ], + } as unknown as SpacesDoc + + /** WHICH PAGES a filter selects — the assertion this whole section is about. + * Not "does this boolean come back true": the failure is a view showing the + * wrong ROWS, and only rows can show it. */ + const sel = (filter: unknown, today?: string): string => + issuesOf(doc).filter((r) => passesFilter(doc, r.values, filter, r.page, today)).map((r) => r.page.id).join('') + + ok(sel(undefined) === 'abcd', 'no filter is every row, exactly as before') + + // ————— THE COMPATIBILITY PROOF ————— + // Every filter shape that could exist in a file written before `where` did, + // over the same rows, selecting what it always selected. If one line here + // moves, files on other people's disks have quietly changed meaning. + ok(sel({ open: true }) === 'abd', 'an OLD open-only filter still selects exactly its rows') + ok(sel({ is: { status: ['todo'] } }) === 'abd', '…an OLD membership filter likewise') + ok(sel({ is: { labels: ['ui'] } }) === 'bd', '…including one over a list-valued field') + ok(sel({ open: true, is: { labels: ['draft'] } }) === 'ad', '…and two old keys still AND together') + ok(sel({ is: { status: [] } }) === 'abcd', '…and an empty list is still NO CONSTRAINT') + ok(sel({}) === 'abcd' && sel({ where: [] }) === 'abcd', + 'an empty filter and an empty clause list are both "everything"') + + // ————— numbers: ranges and comparison ————— + ok(sel({ where: [{ key: 'year', op: 'gt', v: 2020 }] }) === 'bcd', 'published after 2020') + ok(sel({ where: [{ key: 'year', op: 'gte', v: 2021 }] }) === 'bcd', '…inclusively with gte') + ok(sel({ where: [{ key: 'year', op: 'lt', v: 2021 }] }) === 'a', '…and lt is the other side') + ok(sel({ where: [{ key: 'year', op: 'gte', v: 2021 }, { key: 'year', op: 'lte', v: 2024 }] }) === 'bc', + 'two clauses AND into a RANGE — which is what the flat list is for') + ok(sel({ where: [{ key: 'year', op: 'gt', v: '2020' }] }) === 'bcd', + 'a numeric field compares numerically even when the stored operand is a string') + ok(sel({ where: [{ key: 'note', op: 'gt', v: 'alz' }] }) === 'c', + 'a non-numeric field compares as TEXT — the operator is not refused, it answers honestly') + + // ————— dates ————— + // Values are `YYYY-MM-DD`, whose string order IS chronological order, so no + // Date object is anywhere near a comparison. + ok(sel({ where: [{ key: 'due', op: 'lt', v: '2026-01-01' }] }) === 'c', 'due before a date') + ok(sel({ where: [{ key: 'due', op: 'gte', v: '2026-01-05' }] }) === 'ab', '…and on or after one') + ok(sel({ where: [{ key: 'due', op: 'eq', v: '2026-01-10' }] }) === 'b', 'due exactly on a day') + + // RELATIVE dates, with today INJECTED so the rig is not at the mercy of a + // clock. Under TZ=Pacific/Kiritimati (UTC+14) and TZ=Pacific/Niue (UTC-11) + // these must be the same rows — which they are because nothing here parses a + // date string into a Date. + ok(sel({ where: [{ key: 'due', op: 'in', v: 'past' }] }, '2026-01-06') === 'ac', 'overdue = "in the past"') + ok(sel({ where: [{ key: 'due', op: 'in', v: 'future' }] }, '2026-01-06') === 'b', '…and "in the future"') + ok(sel({ where: [{ key: 'due', op: 'in', v: 'today' }] }, '2026-01-05') === 'a', '…and today is one day') + ok(sel({ where: [{ key: 'due', op: 'in', v: 'month' }] }, '2026-01-20') === 'ab', + 'this month is the calendar month, so a December date is out of a January window') + ok(sel({ where: [{ key: 'due', op: 'in', v: 'today' }] }, '2026-01-06') === '', + 'a window nothing falls in selects nothing — and page d, which has no due date at all, is not in it') + + // the week, and its LOCALE-DEPENDENT start. 2026-01-05 is a Monday. + ok(JSON.stringify(windowRange('week', '2026-01-07', 'de-DE')) === '{"from":"2026-01-05","to":"2026-01-11"}', + 'a Monday-start locale puts Wednesday 7 Jan in Mon 5 – Sun 11') + ok(JSON.stringify(windowRange('week', '2026-01-07', 'en-US')) === '{"from":"2026-01-04","to":"2026-01-10"}', + '…and a Sunday-start locale puts the same day in Sun 4 – Sat 10') + ok(JSON.stringify(windowRange('week', '2026-01-07', 'de-DE')) !== JSON.stringify(windowRange('week', '2026-01-07', 'en-US')), + 'so the week a view means is READ from the locale rather than assumed — the two answers differ') + ok(windowRange('today', '2026-03-01')?.from === '2026-03-01', + 'today is one day, whatever the timezone the process is running in') + ok(windowRange('past', '2026-03-01')?.to === '2026-02-28', + 'the day before 1 March 2026 is 28 February — calendar arithmetic, not minus 86,400,000') + ok(windowRange('month', '2026-02-14')?.to === '2026-02-28' && + windowRange('month', '2024-02-14')?.to === '2024-02-29', + '…and a month ends where the calendar says, leap years included') + ok(windowRange('future', '2025-12-31')?.from === '2026-01-01', 'and a window crosses a year end') + // The DST boundary is where epoch arithmetic stops agreeing with a calendar: + // 29 March 2026 is 23 hours long in Berlin, so 30 March minus 86,400,000 ms + // read back in local components is the 28th. Run under TZ=Europe/Berlin. + ok(windowRange('past', '2026-03-30')?.to === '2026-03-29', + 'the day before a spring-forward Monday is the Sunday, not the Saturday') + ok(windowRange('future', '2026-03-28')?.from === '2026-03-29', + '…and the day after the Saturday is that same Sunday') + + // ————— text ————— + ok(sel({ where: [{ key: 'note', op: 'contains', v: 'ALP' }] }) === 'a', 'contains is case-insensitive') + ok(sel({ where: [{ key: 'note', op: 'notContains', v: 'a' }] }) === 'bd', + '…and its negation covers the rows with no value at all') + ok(sel({ where: [{ key: ':title', op: 'contains', v: 'onboarding' }] }) === 'a', + 'a condition can ask about the TITLE, which is not a prop block and no field key could reach') + ok(sel({ where: [{ key: ':title', op: 'notContains', v: 'e' }] }) === '', + '…and it is the real title, not a placeholder') + ok(sel({ where: [{ key: 'labels', op: 'contains', v: 'draft' }] }) === 'ad', + 'contains on a list-valued field reads the joined labels') + ok(sel({ where: [{ key: 'status', op: 'contains', v: 'shipp' }] }) === 'c', + 'and on a select it reads the option LABEL — "Shipped", never the stored id "done", because contains is a question about what is on the screen') + + // ————— negation and absence ————— + ok(sel({ where: [{ key: 'labels', op: 'ne', v: 'draft' }] }) === 'bc', 'pages NOT tagged draft') + ok(sel({ where: [{ key: 'status', op: 'ne', v: 'done' }] }) === 'abd', '…and a select negates the same way') + ok(sel({ where: [{ key: 'due', op: 'empty' }] }) === 'd', 'an unset value is a question of its own') + ok(sel({ where: [{ key: 'due', op: 'notEmpty' }] }) === 'abc', '…in both directions') + ok(sel({ where: [{ key: 'labels', op: 'empty' }] }) === 'c', + 'an EMPTY LIST is empty — [] is the absence of labels, not one label') + ok(sel({ where: [{ key: 'note', op: 'empty' }] }) === 'bd', + 'and so is the empty string, which is what an unset text field holds') + + // ————— all / any ————— + ok(sel({ where: [{ key: 'year', op: 'gt', v: 2020 }, { key: 'labels', op: 'eq', v: 'ui' }] }) === 'bd', + 'clauses AND by default') + ok(sel({ any: true, where: [{ key: 'year', op: 'lt', v: 2020 }, { key: 'status', op: 'eq', v: 'done' }] }) === 'ac', + '…and `any` ORs the same two') + ok(sel({ any: true, where: [{ key: 'year', op: 'gt', v: 2020 }] }) === 'bcd', + '`any` over one clause is that clause') + // `any` reaches ONLY `where`. If it ever reached `is` or `open`, every file + // carrying those keys would change meaning. + ok(sel({ any: true, open: true, where: [{ key: 'status', op: 'eq', v: 'done' }] }) === '', + 'an old key still ANDs with the new list, whatever `any` says') + + // ————— a rule from a NEWER BUILD, one level down ————— + const newer = { where: [{ key: 'year', op: 'approximately', v: 2021 }] } + ok(unknownFilterOps(newer).join() === 'approximately', 'an operator this build cannot evaluate is REPORTED') + ok(unknownFilterOps({ where: [{ key: 'due', op: 'in', v: 'fortnight' }] }).join() === 'in:fortnight', + '…and so is a window word inside an operator this build does know') + ok(unknownFilterOps({ where: [{ key: 'year', op: 'gt', v: 1 }] }).length === 0, '…and a known one is not') + ok(sel(newer) === 'abcd', + 'an unknown operator shows MORE, never fewer — hiding rows for a rule nobody can see is the silent loss') + ok(sel({ any: true, where: [{ key: 'year', op: 'approximately', v: 2021 }, { key: 'year', op: 'lt', v: 2020 }] }) === 'abcd', + '…and under `any` it passes rather than being skipped, which is the same direction') + ok(JSON.parse(JSON.stringify({ type: 'view', filter: newer })).filter.where[0].op === 'approximately', + 'and the rule itself round-trips verbatim') + ok(unknownFilterKeys({ where: [], any: true, open: true }).length === 0, + '`where` and `any` are known keys HERE — it is an older build that reports them, and it already does') + + // ————— a clause out of a file somebody mailed you ————— + // None of this may throw, and none of it may quietly empty a board. + ok(sel({ where: 'drop table' }) === 'abcd', 'a `where` that is not an array is no constraint') + ok(sel({ where: [null, 7, 'x', {}, { key: 'year' }, { op: 'gt' }] }) === 'abcd', + '…and neither is a list of things that are not clauses') + ok(sel({ where: [{ key: 'year', op: 'gt' }] }) === 'abcd', + 'a half-built condition narrows nothing rather than emptying the board') + ok(sel({ where: [{ key: '__proto__', op: 'notEmpty' }] }) === '', + 'a key naming a prototype member reads as an ABSENT field, not as Object.prototype') + ok(sel({ where: [{ key: 'toString', op: 'notEmpty' }] }) === '', + '…and so does the other one that has bitten this app twice') + ok(sel({ where: [{ key: 'year', op: 'gt', v: { toString: 1 } as never }] }) === 'abcd', + 'an operand that is not a string or a number is dropped, not coerced') + ok(clauseCount({ where: [{ key: 'year', op: 'gt', v: 1 }, { key: 'x', op: 'nope', v: 1 }, { key: 'y', op: 'lt' }] }) === 1, + 'the chip counts only what actually narrows') + ok(filterCount({ open: true, is: { status: ['a'] }, where: [{ key: 'year', op: 'gt', v: 1 }] }) === 3, + '…and the Filter chip counts the conditions alongside the old two keys') + ok(filterCount({ where: [] }) === 0 && filterCount(undefined) === 0, + 'and an empty one still counts nothing') + + // ————— what the popover says a clause means ————— + ok(clauseSummary(doc, { key: 'status', op: 'eq', v: 'done' }) === 'Status is Shipped', + 'a summary names the field and shows the option LABEL, not its stored id') + ok(clauseSummary(doc, { key: 'year', op: 'gt', v: 2020 }) === 'Year is more than 2020', + '…and a number gets the words a number takes') + ok(clauseSummary(doc, { key: 'due', op: 'in', v: 'week' }) === 'Due is within This week', + '…and a window is named rather than shown as its stored word') + ok(clauseSummary(doc, { key: ':title', op: 'notEmpty' }) === 'Title is not empty', + '…and an operator with no operand does not leave a dangling one') + + // ————— the operator picker ————— + ok(opsFor('date').includes('in') && !opsFor('number').includes('in'), + 'only a date is offered a relative window') + ok(!opsFor('date').includes('contains') && opsFor('text').includes('contains'), + 'and only text-shaped fields are offered contains') + ok(sel({ where: [{ key: 'due', op: 'contains', v: '2026-01' }] }) === 'ab', + 'but an operator the picker does not OFFER still EVALUATES — a filter from an agent or a newer build must not stop selecting its rows') +} + // ---- the board's writes go through the ONE writer ------------------------- // `value` and `html` must move together on EVERY path, or a status set from the // board is invisible to an older build, a thumbnailer, a grep and the markdown diff --git a/spaces/CHANGELOG.md b/spaces/CHANGELOG.md index 820b8829..4d2067cf 100644 --- a/spaces/CHANGELOG.md +++ b/spaces/CHANGELOG.md @@ -477,6 +477,40 @@ Versions follow `0.MINOR.PATCH` while pre-1.0. 2026, from the same file. `bento.journal()` opens today's for an agent, and `bento.journal('2026-08-06')` any day's. +- **A view's filter can ask a real question.** It had two things to say — "show + me what is open" and "show me these values" — under five layouts that exist to + hold books, tasks and dates. "Published after 2020", "due this week", "not + tagged draft", "title contains onboarding" and "has no due date" were all + unaskable. A view now carries **conditions**: a field, an operator and a + value, built in the Filter popover and listed there in words. + + Eleven operators, chosen per field type rather than collected. Numbers and + dates get `is more than` / `is at least` / `is less than` / `is at most`, and + two of them AND into a range. Dates also get relative windows — Today, This + week, This month, In the past (which is what "overdue" is), In the future — + resolved against **your own day in your own timezone**, and against your own + locale's week: the same file answers "this week" as Monday–Sunday in Berlin + and Sunday–Saturday in Chicago, because a week start is a reader's fact and + not a document's. Text gets `contains` / `does not contain`, matching what is + on the screen rather than what is stored underneath, so a status matches its + label. Everything gets `is` / `is not` and `is empty` / `is not empty` — the + question membership could never ask, because an unset value is the absence of + a value rather than one of its values. A condition can also ask about the page + **title**, which is not a property and no field name could reach. + + Conditions are a flat list with one switch — **Match all** or **Match any** — + and no nesting, deliberately: a filter you cannot read at a glance is worse + than one that cannot ask everything, and the popover is a sheet on a phone. + The Filter chip counts them alongside the old two. + + Nothing written before this changes. Every existing filter runs through + exactly the code it always did and selects exactly the rows it always did, and + a view whose conditions are all removed goes back to being byte-identical to + one nobody ever filtered. An **older build** opening a file with conditions + ignores them, shows a superset of the rows, and says so in the banner it + already had for a newer sort — and a **newer** operator meeting this build + does the same rather than hiding rows for a rule nobody can see. + ## [0.1.0] — 2026-08-03 First release. diff --git a/spaces/src/about.ts b/spaces/src/about.ts index 1485adae..fcb6b5b2 100644 --- a/spaces/src/about.ts +++ b/spaces/src/about.ts @@ -831,7 +831,7 @@ export function toMarkdown(store: Store): string { const field = fieldByKey(doc, groupKey) const rows = sortRows( doc, - issuesOf(doc).filter((r) => passesFilter(doc, r.values, (b as { filter?: unknown }).filter)), + issuesOf(doc).filter((r) => passesFilter(doc, r.values, (b as { filter?: unknown }).filter, r.page)), (b as { sort?: unknown }).sort) // the board's column order, so an export reads top-to-bottom the way the // board reads left-to-right diff --git a/spaces/src/editor.ts b/spaces/src/editor.ts index e682b5b1..3da7fd02 100644 --- a/spaces/src/editor.ts +++ b/spaces/src/editor.ts @@ -30,6 +30,10 @@ import { cycleSort, nextLayout, type DropAim, type FieldSpec, type ViewFilter, type ViewSort, } from './fields' +import { + clausesOf, clauseSummary, isAny, opsFor, opLabel, numberOpLabel, windowLabel, + DATE_WINDOWS, type Clause, type QueryOp, +} from './query.ts' import { planImport, type SourceFile } from './markdown' import { extractSpace, planGraft } from './portable' import { countOutsideTags, replaceOutsideTags } from './findreplace' @@ -1786,6 +1790,17 @@ export class Editor { if (!pop.contains(ev.target as Node)) { this.closeOverlay(); document.removeEventListener('mousedown', away) } } setTimeout(() => document.addEventListener('mousedown', away), 0) + // A POPOVER THAT OPENS ANOTHER POPOVER used to be closed by the first one's + // own dismissal listener. `away` removed itself only when it FIRED, so the + // outgoing popover left it attached; the first mousedown inside the new + // popover was "outside" the old one, and closeOverlay() — which by then + // pointed at the NEW popover — tore down the thing that had just been + // opened. Measured: click Filter → Add condition → click the value box, and + // the form vanished with `document.querySelectorAll('.sp-pop').length === 0` + // before a character could be typed. Nothing had chained popovers before + // the condition builder, so the bug was latent rather than new. + const outgoing = this.overlayReflow + this.overlayReflow = () => { outgoing?.(); document.removeEventListener('mousedown', away) } } /** @@ -1944,13 +1959,147 @@ export class Editor { }) } + /** Add, remove or re-combine a view's typed conditions. */ + private editClauses(blockId: string, edit: (list: Clause[]) => Clause[]): void { + this.editViewFilter(blockId, (f) => { + const next = edit(clausesOf(f)) + // an empty list is DELETED, never stored: the same rule `is` and `open` + // follow, and what keeps a view conditioned and then cleared + // byte-identical to one nobody ever touched + if (next.length) f.where = next + else { delete f.where; delete f.any } + // `any` over one clause is a distinction without a difference, and a + // stored key that changes nothing is a key somebody has to explain + if (next.length < 2) delete f.any + }) + } + /** - * The filter picker: every option of every select field, as toggles. + * Build one condition: a field, an operator, a value. * - * Deliberately NOT a query builder — no operators, no and/or, no nesting. - * A list of values you can switch on is the whole of what a board needs, it - * fits a phone sheet, and it cannot grow a language that then has to be - * supported forever. + * A FORM, not a three-step menu chain. The three parts are one thought and + * picking them through three popovers is the interaction that makes a filter + * builder unusable — and the form is four native controls, which is what + * makes it fit a phone sheet without a layout of its own. + * + * The operator list follows the FIELD TYPE (query.ts `opsFor`), because "is + * more than" on a date and "is after" on a number are both questions nobody + * asks, and the value control follows the OPERATOR: options for a select, a + * window for `in`, a date picker for a date, nothing at all for `is empty`. + */ + private openAddCondition(blockId: string, anchor: HTMLElement): void { + const s = this.store + if (!s.block(blockId) || s.readOnly || this.reading) return + const fields = fieldsOf(s.doc) + this.popover(anchor, (pop) => { + pop.append(el('div', 'sp-pop-title', t('Add a condition'))) + + const wrap = (labelText: string, control: HTMLElement) => { + const row = el('div', 'sp-field') + row.append(el('label', 'sp-field-lbl', labelText), control) + return row + } + const opt = (sel: HTMLSelectElement, value: string, label: string) => { + const o = document.createElement('option') + o.value = value + o.textContent = label + sel.append(o) + } + + const keySel = document.createElement('select') + keySel.className = 'sp-input' + opt(keySel, ':title', t('Title')) + for (const f of fields) opt(keySel, f.key, f.label) + + const opSel = document.createElement('select') + opSel.className = 'sp-input' + + const valBox = el('div', 'sp-field') + const valLbl = el('label', 'sp-field-lbl', t('Value')) + + const fieldNow = () => fields.find((f) => f.key === keySel.value) + const buildOps = () => { + opSel.textContent = '' + const f = fieldNow() + for (const o of opsFor(f?.vt)) opt(opSel, o, f?.vt === 'number' ? numberOpLabel(o) : opLabel(o)) + } + const buildValue = () => { + valBox.textContent = '' + const f = fieldNow() + const op = opSel.value + // `is empty` and `is not empty` are the two questions with no operand, + // so the control is ABSENT rather than disabled — a greyed box invites + // somebody to try to type in it + if (op === 'empty' || op === 'notEmpty') return + valBox.append(valLbl) + if (op === 'in') { + const sel = document.createElement('select') + sel.className = 'sp-input' + for (const w of DATE_WINDOWS) opt(sel, w, windowLabel(w)) + valBox.append(sel) + } else if (f?.options?.length && (op === 'eq' || op === 'ne')) { + const sel = document.createElement('select') + sel.className = 'sp-input' + for (const o of f.options) opt(sel, o.id, o.label) + valBox.append(sel) + } else { + const input = document.createElement('input') + input.className = 'sp-input' + input.type = f?.vt === 'number' ? 'number' : f?.vt === 'date' ? 'date' : 'text' + valBox.append(input) + } + } + keySel.addEventListener('change', () => { buildOps(); buildValue() }) + opSel.addEventListener('change', buildValue) + buildOps() + buildValue() + + pop.append(wrap(t('Field'), keySel), wrap(t('Condition'), opSel), valBox) + + const add = document.createElement('button') + add.type = 'button' + add.className = 'sp-btn sp-primary' + add.textContent = t('Add condition') + add.addEventListener('click', () => { + const op = opSel.value as QueryOp + const input = valBox.querySelector('select, input') as HTMLInputElement | HTMLSelectElement | null + const raw = input ? input.value : '' + // a condition with nothing in its box narrows nothing and would count + // for nothing on the chip — so it is not added at all rather than + // stored as a rule that does not apply + if (!input || raw !== '') { + const c: Clause = { key: keySel.value, op } + if (input) c.v = fieldNow()?.vt === 'number' && Number.isFinite(Number(raw)) ? Number(raw) : raw + this.editClauses(blockId, (list) => [...list, c]) + } + this.closeOverlay() + }) + pop.append(add) + // The popover's keyboard trap focuses the POP, which is right for a list + // of menu items and wrong for a form: you would arrive on a container and + // have to Tab three times to reach the box you opened this to fill in. + // Measured before this line: after clicking Add condition, + // `document.activeElement` was `div.sp-pop`, and typing put the text + // nowhere. The trap runs on its own tick, so this has to as well. + setTimeout(() => keySel.focus(), 0) + }) + } + + /** + * The filter picker: the options of every select field as toggles, and the + * typed conditions under them. + * + * IT USED TO BE TOGGLES ONLY, and the comment here said so as a decision: + * "deliberately NOT a query builder… it cannot grow a language that then has + * to be supported forever." Half of that stands and half of it did not + * survive contact with the app. What stands is the shape — a FLAT list of + * conditions, one all/any switch, no nesting, so the popover is still a list + * you read top to bottom on a phone. What did not is the scope: a view with + * five layouts over a filter that can only ask "which of these values" cannot + * ask what its own layouts exist for — a calendar over dates that cannot say + * "this week", a table over numbers that cannot say "more than". The + * value-toggle list stays FIRST and unchanged, because it is still the one + * question a board is asked most. */ private openViewFilter(blockId: string, anchor: HTMLElement): void { const s = this.store @@ -1984,10 +2133,34 @@ export class Editor { pop.append(item) } } + const conds = clausesOf(cur) + pop.append(el('div', 'sp-fgroup', t('Conditions'))) + for (let i = 0; i < conds.length; i++) { + const at = i + // the row IS the remove control — a summary with a separate ✕ needs a + // layout of its own, and every other list in this popover is already one + // tap = one change + pop.append(this.menuItem('trash', clauseSummary(s.doc, conds[at]), t('Remove'), () => { + this.closeOverlay() + this.editClauses(blockId, (list) => list.filter((_, j) => j !== at)) + })) + } + pop.append(this.menuItem('plus', t('Add condition'), '', () => this.openAddCondition(blockId, anchor))) + if (conds.length > 1) { + const any = isAny(cur) + // ONE switch for the whole list. Per-clause and/or is a tree, and a tree + // needs a UI that can show one; this is the 90% and it reads in a line. + pop.append(this.menuItem('toggle', any ? t('Match any condition') : t('Match all conditions'), + t('Switch between all and any'), () => { + this.closeOverlay() + this.editViewFilter(blockId, (f) => { if (any) delete f.any; else f.any = true }) + })) + } + pop.append(this.menuItem('trash', t('Clear filter'), '', () => { this.closeOverlay() // unknown keys survive: this clears what this build put there - this.editViewFilter(blockId, (f) => { delete f.is; delete f.open }) + this.editViewFilter(blockId, (f) => { delete f.is; delete f.open; delete f.where; delete f.any }) })) }) } diff --git a/spaces/src/fields.ts b/spaces/src/fields.ts index c71ea2ae..67cc079e 100644 --- a/spaces/src/fields.ts +++ b/spaces/src/fields.ts @@ -30,6 +30,7 @@ import type { SpacesDoc, Page, Block } from './model' // not follow an extensionless import. Vite is unaffected — the same fix main // already carries for i18n/packed. import { t } from './i18n.ts' +import { passesClauses, clauseCount, type Clause } from './query.ts' /** What a field holds. Deliberately few: every one costs an editor and a * permanent commitment, and a tracker needs exactly these. */ @@ -218,11 +219,17 @@ export function headerLength(page: Page): number { * empty object, so a view someone filtered and then unfiltered is byte-identical * to one that was never filtered. * - * Deliberately two keys. A filter language is a thing that grows without limit - * and can never shrink — every operator here is in files on other people's - * disks the moment it ships — so this is the smallest pair that answers the two - * questions a tracker is actually asked: "show me this label" and "show me what - * is still open". + * It began as two keys, and that was the right size for a board: "show me this + * label" and "show me what is still open". It was also the whole of what a view + * could ask, on five layouts — so "books published after 2020", "tasks due this + * week", "pages not tagged draft" and "title contains X" were all unexpressible. + * `where` is the third key and the answer; its language, its operators and the + * reasoning behind the shape live in query.ts, which is where a filter language + * that grows should live rather than in the middle of the schema. + * + * The first two keys are UNCHANGED, in meaning and in combination: a file + * written before `where` existed evaluates through exactly the code it always + * did, and `any` narrows nothing but the new list. */ export interface ViewFilter { /** @@ -233,10 +240,14 @@ export interface ViewFilter { is?: Record /** only issues whose phase is neither done nor cancelled */ open?: boolean + /** typed conditions — ranges, dates, text, absence. See query.ts. */ + where?: Clause[] + /** the `where` list is ORed rather than ANDed. Reaches no other key. */ + any?: boolean } /** Filter keys this build can evaluate. */ -const FILTER_KEYS = new Set(['is', 'open']) +const FILTER_KEYS = new Set(['is', 'open', 'where', 'any']) /** * Filter keys from a NEWER build. @@ -272,8 +283,18 @@ export const isOpenPhase = (f: FieldSpec | undefined, value: unknown): boolean = return g !== 'done' && g !== 'cancelled' } -/** Does one issue pass a view's filter? */ -export function passesFilter(doc: SpacesDoc, values: Map, filter: unknown): boolean { +/** + * Does one issue pass a view's filter? + * + * `page` and `today` are OPTIONAL and additive, in the same way the format is: + * every existing call site keeps working and keeps answering what it answered. + * `page` is what lets a condition ask about the title, which is not a prop + * block and so is reachable through no field key; `today` is injected so a rig + * is not at the mercy of a clock. + */ +export function passesFilter( + doc: SpacesDoc, values: Map, filter: unknown, page?: Page, today?: string, +): boolean { if (!filter || typeof filter !== 'object') return true const f = filter as ViewFilter if (f.open) { @@ -292,14 +313,22 @@ export function passesFilter(doc: SpacesDoc, values: Map, filte if (!want.some((w) => mine.includes(String(w)))) return false } } - return true + // The typed conditions are ANDed with everything above, whatever `any` says: + // `any` was added with `where` and reaches only `where`. Making it reach `is` + // or `open` would change what a file already on somebody's disk means. + return passesClauses(doc, values, filter, page, today) } /** How many things a filter narrows by — what the Filter button counts. */ export const filterCount = (filter: unknown): number => { const f = (filter ?? {}) as ViewFilter const is = f.is && typeof f.is === 'object' ? f.is : {} - return (f.open ? 1 : 0) + Object.keys(is).filter((k) => (is[k] ?? []).length).length + return (f.open ? 1 : 0) + + Object.keys(is).filter((k) => (is[k] ?? []).length).length + // a half-built condition counts for nothing, exactly as an empty `is` list + // does — the chip says how much the view is narrowed, not how many rows the + // popover happens to be showing + + clauseCount(filter) } /** diff --git a/spaces/src/i18n/de.ts b/spaces/src/i18n/de.ts index 29921266..e60aa3ea 100644 --- a/spaces/src/i18n/de.ts +++ b/spaces/src/i18n/de.ts @@ -629,4 +629,35 @@ export const de: Catalog = { "Date": "Datum", "Person": "Person", "Labels": "Labels", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "ist", + "is not": "ist nicht", + "is after": "ist nach", + "is on or after": "ist am oder nach", + "is before": "ist vor", + "is on or before": "ist am oder vor", + "contains": "enthält", + "does not contain": "enthält nicht", + "is empty": "ist leer", + "is not empty": "ist nicht leer", + "is within": "liegt in", + "is more than": "ist größer als", + "is at least": "ist mindestens", + "is less than": "ist kleiner als", + "is at most": "ist höchstens", + "This week": "Diese Woche", + "This month": "Dieser Monat", + "In the past": "In der Vergangenheit", + "In the future": "In der Zukunft", + "Add a condition": "Bedingung hinzufügen", + "Add condition": "Bedingung hinzufügen", + "Conditions": "Bedingungen", + "Condition": "Bedingung", + "Value": "Wert", + "Match any condition": "Beliebige Bedingung erfüllen", + "Match all conditions": "Alle Bedingungen erfüllen", + "Switch between all and any": "Zwischen alle und beliebig wechseln", } diff --git a/spaces/src/i18n/es.ts b/spaces/src/i18n/es.ts index 772b824f..184ee448 100644 --- a/spaces/src/i18n/es.ts +++ b/spaces/src/i18n/es.ts @@ -629,4 +629,35 @@ export const es: Catalog = { "Date": "Fecha", "Person": "Persona", "Labels": "Etiquetas", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "es", + "is not": "no es", + "is after": "es posterior a", + "is on or after": "es igual o posterior a", + "is before": "es anterior a", + "is on or before": "es igual o anterior a", + "contains": "contiene", + "does not contain": "no contiene", + "is empty": "está vacío", + "is not empty": "no está vacío", + "is within": "está dentro de", + "is more than": "es mayor que", + "is at least": "es al menos", + "is less than": "es menor que", + "is at most": "es como máximo", + "This week": "Esta semana", + "This month": "Este mes", + "In the past": "En el pasado", + "In the future": "En el futuro", + "Add a condition": "Añadir una condición", + "Add condition": "Añadir condición", + "Conditions": "Condiciones", + "Condition": "Condición", + "Value": "Valor", + "Match any condition": "Cumple cualquier condición", + "Match all conditions": "Cumple todas las condiciones", + "Switch between all and any": "Alternar entre todas y cualquiera", } diff --git a/spaces/src/i18n/fr.ts b/spaces/src/i18n/fr.ts index a7042f28..be137667 100644 --- a/spaces/src/i18n/fr.ts +++ b/spaces/src/i18n/fr.ts @@ -629,4 +629,35 @@ export const fr: Catalog = { "Date": "Date", "Person": "Personne", "Labels": "Étiquettes", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "est", + "is not": "n’est pas", + "is after": "est après", + "is on or after": "est le ou après", + "is before": "est avant", + "is on or before": "est le ou avant", + "contains": "contient", + "does not contain": "ne contient pas", + "is empty": "est vide", + "is not empty": "n’est pas vide", + "is within": "est dans", + "is more than": "est supérieur à", + "is at least": "est au moins", + "is less than": "est inférieur à", + "is at most": "est au plus", + "This week": "Cette semaine", + "This month": "Ce mois-ci", + "In the past": "Dans le passé", + "In the future": "Dans le futur", + "Add a condition": "Ajouter une condition", + "Add condition": "Ajouter la condition", + "Conditions": "Conditions", + "Condition": "Condition", + "Value": "Valeur", + "Match any condition": "Correspond à n’importe quelle condition", + "Match all conditions": "Correspond à toutes les conditions", + "Switch between all and any": "Basculer entre toutes et n’importe laquelle", } diff --git a/spaces/src/i18n/it.ts b/spaces/src/i18n/it.ts index a4f1c6af..dd1ea3fc 100644 --- a/spaces/src/i18n/it.ts +++ b/spaces/src/i18n/it.ts @@ -629,4 +629,35 @@ export const it: Catalog = { "Date": "Data", "Person": "Persona", "Labels": "Etichette", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "è", + "is not": "non è", + "is after": "è dopo", + "is on or after": "è il o dopo", + "is before": "è prima di", + "is on or before": "è il o prima di", + "contains": "contiene", + "does not contain": "non contiene", + "is empty": "è vuoto", + "is not empty": "non è vuoto", + "is within": "è entro", + "is more than": "è maggiore di", + "is at least": "è almeno", + "is less than": "è minore di", + "is at most": "è al massimo", + "This week": "Questa settimana", + "This month": "Questo mese", + "In the past": "Nel passato", + "In the future": "Nel futuro", + "Add a condition": "Aggiungi una condizione", + "Add condition": "Aggiungi condizione", + "Conditions": "Condizioni", + "Condition": "Condizione", + "Value": "Valore", + "Match any condition": "Soddisfa una condizione qualsiasi", + "Match all conditions": "Soddisfa tutte le condizioni", + "Switch between all and any": "Alterna tra tutte e una qualsiasi", } diff --git a/spaces/src/i18n/ja.ts b/spaces/src/i18n/ja.ts index 89841877..1b9cba03 100644 --- a/spaces/src/i18n/ja.ts +++ b/spaces/src/i18n/ja.ts @@ -629,4 +629,35 @@ export const ja: Catalog = { "Date": "日付", "Person": "担当者", "Labels": "ラベル", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} が {value} {op}", + "{field} {op}": "{field} が {op}", + "is": "である", + "is not": "ではない", + "is after": "より後", + "is on or after": "以降", + "is before": "より前", + "is on or before": "以前", + "contains": "を含む", + "does not contain": "を含まない", + "is empty": "空", + "is not empty": "空ではない", + "is within": "の範囲内", + "is more than": "より大きい", + "is at least": "以上", + "is less than": "より小さい", + "is at most": "以下", + "This week": "今週", + "This month": "今月", + "In the past": "過去", + "In the future": "未来", + "Add a condition": "条件を追加", + "Add condition": "条件を追加", + "Conditions": "条件", + "Condition": "条件", + "Value": "値", + "Match any condition": "いずれかの条件に一致", + "Match all conditions": "すべての条件に一致", + "Switch between all and any": "「すべて」と「いずれか」を切り替える", } diff --git a/spaces/src/i18n/packed.ts b/spaces/src/i18n/packed.ts index 2ed45fd6..ffdfffa6 100644 --- a/spaces/src/i18n/packed.ts +++ b/spaces/src/i18n/packed.ts @@ -38,12 +38,14 @@ export const PACKED: Record> = { "Add a card": ["カードを追加","添加卡片","新增卡片","Añadir una tarjeta","Ajouter une carte","Karte hinzufügen","Aggiungi una scheda","Adicionar um cartão"], "Add a card that opens a page": ["ページを開くカードを追加","添加一张打开页面的卡片","新增可開啟頁面的卡片","Añadir una tarjeta que abre una página","Ajouter une carte qui ouvre une page","Karte hinzufügen, die eine Seite öffnet","Aggiungi una scheda che apre una pagina","Adicionar um cartão que abre uma página"], "Add a column after": ["右に列を追加","在右侧添加一列","在右側新增一欄","Añadir una columna después","Ajouter une colonne après","Spalte danach einfügen","Aggiungi una colonna dopo","Adicionar uma coluna depois"], + "Add a condition": ["条件を追加","添加条件","新增條件","Añadir una condición","Ajouter une condition","Bedingung hinzufügen","Aggiungi una condizione","Adicionar uma condição"], "Add a link": ["リンクを追加","添加链接","新增連結","Añadir un enlace","Ajouter un lien","Link hinzufügen","Aggiungi un collegamento","Adicionar um link"], "Add a page": ["ページを追加","添加页面","新增頁面","Añadir una página","Ajouter une page","Seite hinzufügen","Aggiungi una pagina","Adicionar uma página"], "Add a picture": ["画像を追加","添加图片","新增圖片","Añadir una imagen","Ajouter une image","Bild hinzufügen","Aggiungi un'immagine","Adicionar uma imagem"], "Add a property": ["プロパティを追加","添加属性","新增屬性","Añadir una propiedad","Ajouter une propriété","Eine Eigenschaft hinzufügen","Aggiungi una proprietà","Adicionar uma propriedade"], "Add a row below": ["下に行を追加","在下方添加一行","在下方新增一列","Añadir una fila debajo","Ajouter une ligne en dessous","Zeile darunter einfügen","Aggiungi una riga sotto","Adicionar uma linha abaixo"], "Add below": ["下に追加","在下方添加","在下方新增","Añadir debajo","Ajouter en dessous","Darunter einfügen","Aggiungi sotto","Adicionar abaixo"], + "Add condition": ["条件を追加","添加条件","新增條件","Añadir condición","Ajouter la condition","Bedingung hinzufügen","Aggiungi condizione","Adicionar condição"], "Add pages under": ["ページを追加する場所","将页面添加到","將頁面加入到","Añadir las páginas debajo de","Ajouter les pages sous","Seiten einfügen unter","Aggiungi le pagine sotto","Adicionar as páginas por baixo de"], "Add property…": ["プロパティを追加…","添加属性…","新增屬性…","Añadir propiedad…","Ajouter une propriété…","Eigenschaft hinzufügen…","Aggiungi proprietà…","Adicionar propriedade…"], "Added {name}": ["{name} を追加しました","已添加{name}","已新增{name}","Se añadió {name}","{name} ajoutée","{name} hinzugefügt","{name} aggiunta","{name} adicionada"], @@ -123,6 +125,8 @@ export const PACKED: Record> = { "Comment · block": ["コメント · ブロック","评论 · 块","註解 · 區塊","Comentario · bloque","Commentaire · bloc","Kommentar · Block","Commento · blocco","Comentário · bloco"], "Comment · page": ["コメント · ページ","评论 · 页面","註解 · 頁面","Comentario · página","Commentaire · page","Kommentar · Seite","Commento · pagina","Comentário · página"], "Comment:": ["コメント:","评论:","註解:","Comentario:","Commentaire :","Kommentar:","Commento:","Comentário:"], + "Condition": ["条件","条件","條件","Condición","Condition","Bedingung","Condizione","Condição"], + "Conditions": ["条件","条件","條件","Condiciones","Conditions","Bedingungen","Condizioni","Condições"], "Connect to the live session without saving a new copy — copies you sent earlier will meet you there.": ["新しいコピーを保存せずにライブセッションへ接続します — 以前送ったコピーがここで合流します。","无需保存新副本即可连接实时会话 — 之前发送的副本会在这里与你会合。","無需儲存新副本即可連線即時會話 — 之前傳送的副本會在這裡與你會合。","Conéctate a la sesión en vivo sin guardar una copia nueva — las copias ya enviadas se unirán aquí.","Connectez-vous à la session live sans enregistrer de copie — les copies déjà envoyées vous y rejoindront.","Mit der Live-Sitzung verbinden, ohne eine neue Kopie zu speichern — bereits gesendete Kopien treffen dich hier.","Connettiti alla sessione live senza salvare una nuova copia — le copie già inviate ti raggiungeranno qui.","Conecte-se à sessão ao vivo sem salvar uma nova cópia — as cópias que você enviou antes encontrarão você lá."], "Connecting…": ["接続中…","连接中…","連線中…","Conectando…","Connexion…","Verbinden…","Connessione…","Conectando…"], "Contents": ["目次","目录","目錄","Índice","Sommaire","Inhalt","Indice","Índice"], @@ -224,6 +228,8 @@ export const PACKED: Record> = { "Imported": ["読み込みました","已导入","已匯入","Importado","Importé","Importiert","Importato","Importado"], "Imported a space": ["スペースを取り込みました","已导入一个空间","已匯入一個空間","Espacio importado","Espace importé","Space importiert","Spazio importato","Espaço importado"], "Imported notes": ["読み込んだノート","导入的笔记","匯入的筆記","Notas importadas","Notes importées","Importierte Notizen","Note importate","Notas importadas"], + "In the future": ["未来","将来","未來","En el futuro","Dans le futur","In der Zukunft","Nel futuro","No futuro"], + "In the past": ["過去","过去","過去","En el pasado","Dans le passé","In der Vergangenheit","Nel passato","No passado"], "Include archived pages": ["アーカイブしたページを含める","包含已归档页面","包含已封存的頁面","Incluir páginas archivadas","Inclure les pages archivées","Archivierte Seiten einbeziehen","Includi le pagine archiviate","Incluir páginas arquivadas"], "Include the image files and they are embedded too. An image this browser cannot open is kept as its path rather than as a broken picture.": ["画像ファイルも一緒に選べば、そのまま埋め込まれます。このブラウザーが開けない画像は、壊れた画像ではなくパスのまま残ります。","把图片文件一起选上,它们也会被嵌入。这个浏览器打不开的图片会以路径的形式保留,而不是一张坏掉的图。","把圖片檔案一起選取,它們也會一併嵌入。這個瀏覽器打不開的圖片會以路徑的形式保留,而不是一張破圖。","Incluye los archivos de imagen y también se incrustan. Una imagen que este navegador no puede abrir se conserva como su ruta, no como una imagen rota.","Ajoutez les fichiers image et ils sont intégrés eux aussi. Une image que ce navigateur ne peut pas ouvrir reste sous forme de chemin, plutôt qu’en image cassée.","Nimm die Bilddateien mit, dann werden sie gleich eingebettet. Ein Bild, das dieser Browser nicht öffnen kann, bleibt als Pfad stehen statt als kaputtes Bild.","Includi anche i file immagine e vengono incorporati. Un’immagine che questo browser non riesce ad aprire resta come percorso, invece che come immagine rotta.","Inclua os ficheiros de imagem e também são incorporados. Uma imagem que este navegador não consegue abrir fica como o seu caminho, em vez de uma imagem partida."], "Include the pages nested under it": ["その下にあるページも含める","包含其下级页面","包含其下層頁面","Incluir las páginas anidadas debajo","Inclure les pages imbriquées en dessous","Die darunter verschachtelten Seiten einschließen","Includi le pagine annidate sotto di essa","Incluir as páginas aninhadas por baixo"], @@ -272,6 +278,8 @@ export const PACKED: Record> = { "Loop": ["ループ","循环","循環","Bucle","Boucle","Schleife","Loop","Repetição"], "Make this page an issue": ["このページをイシューにする","将此页面变为事项","將此頁面變為項目","Convertir esta página en incidencia","Transformer cette page en ticket","Diese Seite zu einem Issue machen","Trasforma questa pagina in una issue","Transformar esta página numa tarefa"], "Manual order": ["手動の並び順","手动排序","手動排序","Orden manual","Ordre manuel","Manuelle Reihenfolge","Ordine manuale","Ordem manual"], + "Match all conditions": ["すべての条件に一致","满足全部条件","符合所有條件","Cumple todas las condiciones","Correspond à toutes les conditions","Alle Bedingungen erfüllen","Soddisfa tutte le condizioni","Corresponde a todas as condições"], + "Match any condition": ["いずれかの条件に一致","满足任一条件","符合任一條件","Cumple cualquier condición","Correspond à n’importe quelle condition","Beliebige Bedingung erfüllen","Soddisfa una condizione qualsiasi","Corresponde a qualquer condição"], "Match my system": ["システムに合わせる","跟随系统","跟隨系統","Según el sistema","Comme le système","Systemeinstellung folgen","Come il sistema","Acompanhar o sistema"], "Mints brand-new keys. Every previously sent copy stops syncing for good; share fresh copies afterwards.": ["まったく新しい鍵を発行します。これまでに送ったコピーは永久に同期しなくなります。以後は新しいコピーを共有してください。","铸造全新密钥。之前发送的所有副本将永久停止同步;此后请分享新副本。","鑄造全新金鑰。之前傳送的所有副本將永久停止同步;此後請分享新副本。","Genera claves totalmente nuevas. Todas las copias enviadas dejan de sincronizarse para siempre; comparte copias nuevas después.","Génère des clés toutes neuves. Chaque copie déjà envoyée cesse définitivement de se synchroniser ; partagez ensuite de nouvelles copies.","Erzeugt brandneue Schlüssel. Jede bereits gesendete Kopie synchronisiert endgültig nicht mehr; teile danach frische Kopien.","Genera chiavi nuove di zecca. Ogni copia già inviata smette per sempre di sincronizzarsi; condividi poi copie nuove.","Gera chaves novas em folha. Toda cópia enviada antes para de sincronizar de vez; compartilhe cópias novas depois."], "More": ["その他","更多","更多","Más","Plus","Mehr","Altro","Mais"], @@ -437,6 +445,7 @@ export const PACKED: Record> = { "Stopped sharing": ["共有を停止しました","已停止共享","已停止分享","Has dejado de compartir","Partage arrêté","Teilen beendet","Condivisione interrotta","Partilha terminada"], "Strikethrough": ["取り消し線","删除线","刪除線","Tachado","Barré","Durchgestrichen","Barrato","Tachado"], "Strikethrough — ⇧⌘S": ["取り消し線 — ⇧⌘S","删除线 — ⇧⌘S","刪除線 — ⇧⌘S","Tachado — ⇧⌘S","Barré — ⇧⌘S","Durchgestrichen — ⇧⌘S","Barrato — ⇧⌘S","Tachado — ⇧⌘S"], + "Switch between all and any": ["「すべて」と「いずれか」を切り替える","在“全部”和“任一”之间切换","在「全部」與「任一」之間切換","Alternar entre todas y cualquiera","Basculer entre toutes et n’importe laquelle","Zwischen alle und beliebig wechseln","Alterna tra tutte e una qualsiasi","Alternar entre todas e qualquer"], "Table": ["表","表格","表格","Tabla","Tableau","Tabelle","Tabella","Tabela"], "Take it elsewhere": ["外に持ち出す","把内容带走","把內容帶走","Llévatelo a otra parte","Emportez-le ailleurs","Woanders weitermachen","Portalo altrove","Levar para outro lado"], "Taken from the address if blank": ["空欄ならアドレスから取り込みます","留空则取自网址","留空則取自網址","Si se deja vacío, se toma de la dirección","Si vide, repris de l’adresse","Bleibt das Feld leer, wird er aus der Adresse übernommen","Se vuoto, viene preso dall'indirizzo","Se ficar vazio, é retirado do endereço"], @@ -489,12 +498,14 @@ export const PACKED: Record> = { "This is not a bento/spaces document — {detail}.": ["これは bento/spaces のドキュメントではありません — {detail}。","这不是 bento/spaces 文档 — {detail}。","這不是 bento/spaces 文件 — {detail}。","Esto no es un documento de bento/spaces — {detail}.","Ce n’est pas un document bento/spaces — {detail}.","Dies ist kein bento/spaces-Dokument — {detail}.","Questo non è un documento bento/spaces — {detail}.","Isto não é um documento bento/spaces — {detail}."], "This list": ["この一覧","本列表","本清單","Esta lista","Cette liste","Diese Liste","Questo elenco","Esta lista"], "This live session has run out of room. Your change is saved in your copy, but collaborators won’t see it.": ["このライブセッションの容量がいっぱいです。変更は自分のコピーに保存されますが、共同編集者には表示されません。","此实时会话空间已满。你的更改已保存在你的副本中,但协作者不会看到。","此即時工作階段空間已滿。你的變更已儲存在你的副本中,但協作者不會看到。","Esta sesión en vivo se ha quedado sin espacio. Tu cambio se guarda en tu copia, pero los colaboradores no lo verán.","Cette session en direct n’a plus de place. Votre modification est enregistrée dans votre copie, mais les collaborateurs ne la verront pas.","Diese Live-Sitzung hat keinen Platz mehr. Deine Änderung ist in deiner Kopie gespeichert, aber Mitarbeitende sehen sie nicht.","Questa sessione dal vivo ha esaurito lo spazio. La tua modifica è salvata nella tua copia, ma i collaboratori non la vedranno.","Esta sessão ao vivo ficou sem espaço. Sua alteração fica salva na sua cópia, mas os colaboradores não a verão."], + "This month": ["今月","本月","本月","Este mes","Ce mois-ci","Dieser Monat","Questo mese","Este mês"], "This page only": ["このページだけ","仅此页面","僅此頁面","Solo esta página","Cette page seulement","Nur diese Seite","Solo questa pagina","Apenas esta página"], "This space has no live session to follow": ["この Space には追従できるライブセッションがありません","此 Space 没有可跟随的实时会话","此 Space 沒有可跟隨的即時會話","Este Space no tiene sesión en vivo que seguir","Cet espace n’a pas de session live à suivre","Dieser Space hat keine Live-Sitzung zum Folgen","Questo Space non ha una sessione live da seguire","Este Space não tem sessão ao vivo para acompanhar"], "This space has no pages.": ["このスペースにはページがありません。","此空间没有页面。","此空間沒有任何頁面。","Este espacio no tiene páginas.","Cet espace n’a aucune page.","Dieser Space hat keine Seiten.","Questo spazio non ha pagine.","Este espaço não tem páginas."], "This space is encrypted, so no versions are kept.": ["このスペースは暗号化されているため、バージョンは保存されません。","此空间已加密,因此不保存任何版本。","此空間已加密,因此不保存任何版本。","Este espacio está cifrado, así que no se guarda ninguna versión.","Cet espace est chiffré : aucune version n’est conservée.","Dieser Space ist verschlüsselt, deshalb werden keine Versionen aufbewahrt.","Questo spazio è cifrato, quindi non viene conservata alcuna versione.","Este espaço está cifrado, por isso não são guardadas versões."], "This space is encrypted. Saves stay encrypted.": ["このスペースは暗号化されています。保存しても暗号化されたままです。","此空间已加密。保存时仍保持加密。","此空間已加密。往後的儲存也會維持加密。","Este espacio está cifrado. Los guardados siguen cifrados.","Cet espace est chiffré. Les enregistrements restent chiffrés.","Dieser Space ist verschlüsselt. Speichern bleibt verschlüsselt.","Questo spazio è cifrato. I salvataggi restano cifrati.","Este espaço está encriptado. Continua encriptado sempre que guardar."], "This space is locked": ["このスペースはロックされています","此空间已锁定","此空間已鎖定","Este espacio está bloqueado","Cet espace est verrouillé","Dieser Space ist gesperrt","Questo spazio è bloccato","Este espaço está bloqueado"], + "This week": ["今週","本周","本週","Esta semana","Cette semaine","Diese Woche","Questa settimana","Esta semana"], "This window is still running v{v} — reload to finish. A v{v} backup was downloaded.": ["このウィンドウはまだ v{v} で動作中 — 再読込で完了します。v{v} のバックアップをダウンロード済み。","此窗口仍在运行 v{v} — 重新加载以完成。已下载 v{v} 备份。","此視窗仍在執行 v{v} — 重新載入以完成。已下載 v{v} 備份。","Esta ventana sigue en v{v} — recarga para terminar. Se descargó una copia de seguridad v{v}.","Cette fenêtre tourne encore en v{v} — rechargez pour terminer. Une sauvegarde v{v} a été téléchargée.","Dieses Fenster läuft noch mit v{v} — zum Abschließen neu laden. Ein v{v}-Backup wurde heruntergeladen.","Questa finestra esegue ancora la v{v} — ricarica per completare. È stato scaricato un backup v{v}.","Esta janela ainda está executando a v{v} — recarregue para concluir. Um backup da v{v} foi baixado."], "This window is still running v{v} — reload to finish. A v{v} backup was saved beside this file.": ["このウィンドウはまだ v{v} で動作中 — 再読込で完了します。v{v} のバックアップをこのファイルの隣に保存しました。","此窗口仍在运行 v{v} — 重新加载以完成。已在此文件旁保存 v{v} 备份。","此視窗仍在執行 v{v} — 重新載入以完成。已在此檔案旁儲存 v{v} 備份。","Esta ventana sigue en v{v} — recarga para terminar. Se guardó una copia de seguridad v{v} junto a este archivo.","Cette fenêtre tourne encore en v{v} — rechargez pour terminer. Une sauvegarde v{v} a été enregistrée à côté de ce fichier.","Dieses Fenster läuft noch mit v{v} — zum Abschließen neu laden. Ein v{v}-Backup wurde neben dieser Datei gespeichert.","Questa finestra esegue ancora la v{v} — ricarica per completare. Un backup v{v} è stato salvato accanto a questo file.","Esta janela ainda está executando a v{v} — recarregue para concluir. Um backup da v{v} foi salvo ao lado deste arquivo."], "This window is still running v{v}. If you overwrote the file that's open here, reload; otherwise open the file you saved.": ["このウィンドウはまだ v{v} で動作中。開いているファイルを上書きした場合は再読込を。別に保存した場合はそのファイルを開いてください。","此窗口仍在运行 v{v}。若覆盖了当前打开的文件请重新加载;否则请打开您保存的文件。","此視窗仍在執行 v{v}。若已覆寫目前開啟的檔案請重新載入;否則請開啟您儲存的檔案。","Esta ventana sigue en v{v}. Si sobrescribiste el archivo abierto aquí, recarga; si no, abre el archivo que guardaste.","Cette fenêtre tourne encore en v{v}. Si vous avez écrasé le fichier ouvert ici, rechargez ; sinon ouvrez le fichier enregistré.","Dieses Fenster läuft noch mit v{v}. Wenn Sie die geöffnete Datei überschrieben haben, neu laden; andernfalls die gespeicherte Datei öffnen.","Questa finestra esegue ancora la v{v}. Se hai sovrascritto il file aperto qui, ricarica; altrimenti apri il file salvato.","Esta janela ainda está executando a v{v}. Se você sobrescreveu o arquivo aberto aqui, recarregue; caso contrário, abra o arquivo que salvou."], @@ -529,6 +540,7 @@ export const PACKED: Record> = { "Updates": ["更新","更新","更新","Actualizaciones","Mises à jour","Updates","Aggiornamenti","Atualizações"], "Use a link…": ["リンクを使う…","使用链接…","使用連結…","Usar un enlace…","Utiliser un lien…","Einen Link verwenden…","Usa un link…","Usar uma ligação…"], "Use this width for every page": ["この幅をすべてのページに使う","将此宽度用于所有页面","將此寬度用於所有頁面","Usar esta anchura en todas las páginas","Utiliser cette largeur pour toutes les pages","Diese Breite für alle Seiten verwenden","Usa questa larghezza per tutte le pagine","Usar esta largura em todas as páginas"], + "Value": ["値","值","值","Valor","Valeur","Wert","Valore","Valor"], "Verifies and builds the new version with this document inside, then asks where to save it — pick the file you have open to update it.": ["このドキュメントを含む新バージョンを検証・生成し、保存先を尋ねます — 開いているファイルを選べば更新されます。","验证并生成包含此文档的新版本,然后询问保存位置 — 选择当前打开的文件即可更新。","驗證並建立包含此文件的新版本,然後詢問儲存位置 — 選擇目前開啟的檔案即可更新。","Verifica y construye la nueva versión con este documento dentro, y pregunta dónde guardarla — elige el archivo que tienes abierto para actualizarlo.","Vérifie et construit la nouvelle version avec ce document, puis demande où l'enregistrer — choisissez le fichier ouvert pour le mettre à jour.","Verifiziert und erstellt die neue Version mit diesem Dokument und fragt nach dem Speicherort — wählen Sie die geöffnete Datei, um sie zu aktualisieren.","Verifica e costruisce la nuova versione con questo documento, poi chiede dove salvarla — scegli il file aperto per aggiornarlo.","Verifica e monta a nova versão com este documento dentro e depois pergunta onde salvá-la — escolha o arquivo que você tem aberto para atualizá-lo."], "Verifying…": ["検証中…","验证中…","驗證中…","Verificando…","Vérification…","Verifiziere…","Verifica…","Verificando…"], "Version {v} is available.": ["バージョン {v} が利用可能です。","版本 {v} 可用。","版本 {v} 可用。","Versión {v} disponible.","La version {v} est disponible.","Version {v} ist verfügbar.","È disponibile la versione {v}.","A versão {v} está disponível."], @@ -564,12 +576,29 @@ export const PACKED: Record> = { "Your name (shown on new comments):": ["あなたの名前(新しいコメントに表示):","您的名字(显示在新评论中):","您的名字(顯示於新註解):","Tu nombre (visible en los nuevos comentarios):","Votre nom (affiché sur les nouveaux commentaires) :","Ihr Name (bei neuen Kommentaren sichtbar):","Il tuo nome (visibile nei nuovi commenti):","Seu nome (exibido em novos comentários):"], "archived": ["アーカイブ済み","已归档","已封存","archivada","archivée","archiviert","archiviata","arquivada"], "bento/spaces is MIT-licensed and carries no third-party runtime — the full notices travel in this file’s source.": ["bento/spaces は MIT ライセンスで、サードパーティのランタイムは含みません。完全な表示はこのファイルのソースに入っています。","bento/spaces 采用 MIT 许可,且不含任何第三方运行时——完整声明就在这个文件的源码里。","bento/spaces 採用 MIT 授權,且不含任何第三方執行環境——完整聲明就在這個檔案的原始碼裡。","bento/spaces tiene licencia MIT y no incluye ningún runtime de terceros: los avisos completos viajan en el código fuente de este archivo.","bento/spaces est sous licence MIT et n’embarque aucun runtime tiers — les mentions complètes voyagent dans le source de ce fichier.","bento/spaces steht unter MIT-Lizenz und enthält keine Fremd-Runtime — die vollständigen Hinweise reisen im Quelltext dieser Datei mit.","bento/spaces è rilasciato con licenza MIT e non include alcun runtime di terze parti: le note complete viaggiano nel sorgente di questo file.","O bento/spaces tem licença MIT e não inclui qualquer runtime de terceiros — os avisos completos viajam no código-fonte deste ficheiro."], + "contains": ["を含む","包含","包含","contiene","contient","enthält","contiene","contém"], + "does not contain": ["を含まない","不包含","不包含","no contiene","ne contient pas","enthält nicht","non contiene","não contém"], "format v{v}": ["フォーマット v{v}","格式 v{v}","格式 v{v}","formato v{v}","format v{v}","Format v{v}","formato v{v}","formato v{v}"], + "is": ["である","是","是","es","est","ist","è","é"], + "is after": ["より後","晚于","晚於","es posterior a","est après","ist nach","è dopo","é depois de"], + "is at least": ["以上","不小于","不小於","es al menos","est au moins","ist mindestens","è almeno","é pelo menos"], + "is at most": ["以下","不大于","不大於","es como máximo","est au plus","ist höchstens","è al massimo","é no máximo"], + "is before": ["より前","早于","早於","es anterior a","est avant","ist vor","è prima di","é antes de"], + "is empty": ["空","为空","為空","está vacío","est vide","ist leer","è vuoto","está vazio"], + "is less than": ["より小さい","小于","小於","es menor que","est inférieur à","ist kleiner als","è minore di","é menor que"], + "is more than": ["より大きい","大于","大於","es mayor que","est supérieur à","ist größer als","è maggiore di","é maior que"], + "is not": ["ではない","不是","不是","no es","n’est pas","ist nicht","non è","não é"], + "is not empty": ["空ではない","不为空","不為空","no está vacío","n’est pas vide","ist nicht leer","non è vuoto","não está vazio"], + "is on or after": ["以降","不早于","不早於","es igual o posterior a","est le ou après","ist am oder nach","è il o dopo","é em ou depois de"], + "is on or before": ["以前","不晚于","不晚於","es igual o anterior a","est le ou avant","ist am oder vor","è il o prima di","é em ou antes de"], + "is within": ["の範囲内","位于","位於","está dentro de","est dans","liegt in","è entro","está dentro de"], "just now": ["たった今","刚刚","剛剛","ahora mismo","à l'instant","gerade eben","proprio ora","agora mesmo"], "most recent": ["最新","最新","最新","más reciente","la plus récente","neueste","più recente","mais recente"], "none": ["なし","无","無","ninguna","aucun","keine","nessuno","nenhuma"], "you": ["あなた","你","你","tú","vous","du","tu","você"], "you: {name} ✎": ["あなた: {name} ✎","你:{name} ✎","你:{name} ✎","tú: {name} ✎","vous : {name} ✎","Sie: {name} ✎","tu: {name} ✎","você: {name} ✎"], + "{field} {op}": ["{field} が {op}","{field} {op}","{field} {op}","{field} {op}","{field} {op}","{field} {op}","{field} {op}","{field} {op}"], + "{field} {op} {value}": ["{field} が {value} {op}","{field} {op} {value}","{field} {op} {value}","{field} {op} {value}","{field} {op} {value}","{field} {op} {value}","{field} {op} {value}","{field} {op} {value}"], "{i} of {n}": ["{i} / {n}","第 {i} 个,共 {n} 个","{i} / {n}","{i} de {n}","{i} sur {n}","{i} von {n}","{i} di {n}","{i} de {n}"], "{name} joined": ["{name} が参加しました","{name} 已加入","{name} 已加入","{name} se ha unido","{name} a rejoint","{name} ist beigetreten","{name} si è unito","{name} entrou"], "{name} left": ["{name} が退出しました","{name} 已离开","{name} 已離開","{name} se ha ido","{name} est parti","{name} hat verlassen","{name} se n'è andato","{name} saiu"], diff --git a/spaces/src/i18n/pt.ts b/spaces/src/i18n/pt.ts index 0e26b5a6..fc156b6f 100644 --- a/spaces/src/i18n/pt.ts +++ b/spaces/src/i18n/pt.ts @@ -629,4 +629,35 @@ export const pt: Catalog = { "Date": "Data", "Person": "Pessoa", "Labels": "Etiquetas", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "é", + "is not": "não é", + "is after": "é depois de", + "is on or after": "é em ou depois de", + "is before": "é antes de", + "is on or before": "é em ou antes de", + "contains": "contém", + "does not contain": "não contém", + "is empty": "está vazio", + "is not empty": "não está vazio", + "is within": "está dentro de", + "is more than": "é maior que", + "is at least": "é pelo menos", + "is less than": "é menor que", + "is at most": "é no máximo", + "This week": "Esta semana", + "This month": "Este mês", + "In the past": "No passado", + "In the future": "No futuro", + "Add a condition": "Adicionar uma condição", + "Add condition": "Adicionar condição", + "Conditions": "Condições", + "Condition": "Condição", + "Value": "Valor", + "Match any condition": "Corresponde a qualquer condição", + "Match all conditions": "Corresponde a todas as condições", + "Switch between all and any": "Alternar entre todas e qualquer", } diff --git a/spaces/src/i18n/zh-Hans.ts b/spaces/src/i18n/zh-Hans.ts index f97f50a2..7720cd59 100644 --- a/spaces/src/i18n/zh-Hans.ts +++ b/spaces/src/i18n/zh-Hans.ts @@ -629,4 +629,35 @@ export const zh_Hans: Catalog = { "Date": "日期", "Person": "人员", "Labels": "标签", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "是", + "is not": "不是", + "is after": "晚于", + "is on or after": "不早于", + "is before": "早于", + "is on or before": "不晚于", + "contains": "包含", + "does not contain": "不包含", + "is empty": "为空", + "is not empty": "不为空", + "is within": "位于", + "is more than": "大于", + "is at least": "不小于", + "is less than": "小于", + "is at most": "不大于", + "This week": "本周", + "This month": "本月", + "In the past": "过去", + "In the future": "将来", + "Add a condition": "添加条件", + "Add condition": "添加条件", + "Conditions": "条件", + "Condition": "条件", + "Value": "值", + "Match any condition": "满足任一条件", + "Match all conditions": "满足全部条件", + "Switch between all and any": "在“全部”和“任一”之间切换", } diff --git a/spaces/src/i18n/zh-Hant.ts b/spaces/src/i18n/zh-Hant.ts index 85526d58..83c1dbf8 100644 --- a/spaces/src/i18n/zh-Hant.ts +++ b/spaces/src/i18n/zh-Hant.ts @@ -629,4 +629,35 @@ export const zh_Hant: Catalog = { "Date": "日期", "Person": "人員", "Labels": "標籤", + + // view conditions (query.ts) + "{field} {op} {value}": "{field} {op} {value}", + "{field} {op}": "{field} {op}", + "is": "是", + "is not": "不是", + "is after": "晚於", + "is on or after": "不早於", + "is before": "早於", + "is on or before": "不晚於", + "contains": "包含", + "does not contain": "不包含", + "is empty": "為空", + "is not empty": "不為空", + "is within": "位於", + "is more than": "大於", + "is at least": "不小於", + "is less than": "小於", + "is at most": "不大於", + "This week": "本週", + "This month": "本月", + "In the past": "過去", + "In the future": "未來", + "Add a condition": "新增條件", + "Add condition": "新增條件", + "Conditions": "條件", + "Condition": "條件", + "Value": "值", + "Match any condition": "符合任一條件", + "Match all conditions": "符合所有條件", + "Switch between all and any": "在「全部」與「任一」之間切換", } diff --git a/spaces/src/query.ts b/spaces/src/query.ts new file mode 100644 index 00000000..e4740860 --- /dev/null +++ b/spaces/src/query.ts @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// View conditions: the part of a view's filter that asks a real question. +// +// WHAT WAS MISSING. `ViewFilter` shipped with two keys — `open` and `is` — and +// that is membership and nothing else. "Books published after 2020", "tasks due +// this week", "pages not tagged draft", "title contains onboarding" were all +// unexpressible, on five layouts sitting over a filter that could not ask them. +// +// THE SHAPE, and why it is this one rather than a tree. +// +// · ONE NEW LIST, `filter.where`, of FLAT clauses, ANDed. Plus one boolean, +// `filter.any`, that ORs them instead. That is the whole language. +// · NO NESTING, deliberately, and this is a judgement rather than an omission. +// A nested group needs a UI that can show, build and unbuild a tree, and a +// filter nobody can read is worse than one that cannot ask everything — the +// popover is a phone sheet. "Due this week AND not tagged draft" and "urgent +// OR overdue" are the two shapes people actually ask for and both are flat. +// And nesting REMAINS AVAILABLE: another key added later is additive in +// exactly the way widening a flat list into a tree afterwards would not be. +// · `open` AND `is` KEEP THEIR MEANING AND THEIR COMBINATION. The result is +// `open AND is AND (where, combined by all-or-any)`. `any` reaches only the +// new list, because making it reach `is` would change what an existing file +// means, and no key already on somebody's disk may change meaning. +// +// WHAT AN OLDER BUILD DOES WITH THIS. It ignores `where` and `any` entirely — +// they are unknown keys, they round-trip untouched, and the view shows a +// SUPERSET of the rows the author asked for. It also SAYS SO: `where` and `any` +// fall out of `unknownFilterKeys` there, and render.ts already turns that into +// "A filter here is newer than this build and was not applied." That is the +// existing trade, unchanged — additivity keeps the rule, honesty says the rule +// was not applied. What must never happen is a silently WRONG set of rows, and +// a superset with a banner over it is not that. +// +// THE SAME RULE ONE LEVEL DOWN, which is the new part. An operator this build +// does not know is NOT a reason to hide rows. `unknownFilterOps` reports it and +// the clause is treated as no constraint — matching `isOpenPhase`'s existing +// precedent ("an UNKNOWN value counts as open… showing one issue too many is +// not a silent loss") and `is`'s ("an empty list is NO CONSTRAINT"). Under +// `any` that inverts: skipping a clause in an OR would make the group NARROWER, +// so an unknown clause there PASSES, which is the same direction — show more, +// never less, and put a banner over it. +// +// NO `eval`, NO `new Function`, AND THAT IS A SECURITY BOUNDARY. A filter comes +// out of a file somebody mailed you, exactly like block html. calc.ts argues +// this at length for arithmetic and the argument is identical here: an +// expression evaluator that reached for the JS parser would hand back precisely +// what sanitize.ts exists to prevent. This is a fixed operator table over typed +// values and it can only ever return a boolean. +// +// DATES NEVER TOUCH `new Date(string)`. A `date` field holds what `` holds — `YYYY-MM-DD`, no zone — so comparison is STRING +// comparison, which is chronological for that shape in every timezone on earth. +// The relative windows are built from journal.ts's `todayISO`/`stepDay`, which +// are calendar arithmetic in the READER'S OWN zone. `new Date('2026-01-01')` is +// UTC midnight by spec and therefore the previous day for half the world; it +// does not appear in this file and must not. + +import type { SpacesDoc, Page } from './model.ts' +import { fieldByKey, optionOf, type FieldSpec } from './fields.ts' +import { todayISO, stepDay } from './journal.ts' +import { t } from './i18n.ts' + +/** + * The operators. PERMANENT — every one of these ships into files on other + * people's disks the moment it is released, and the word can never be reused + * for anything else. + * + * Eleven, argued per field type rather than assembled from a wish list: + * + * · `eq` / `ne` — every type. Exact match on the STORED value, because that + * is what the picker supplies (a select stores an option id). For a + * multi-value field (`labels`) `eq` is MEMBERSHIP and `ne` its negation, + * mirroring what `is` already does for arrays rather than inventing a + * second spelling of the same question. + * · `gt` / `gte` / `lt` / `lte` — `number` and `date`. Numbers compare + * numerically; dates compare as ISO strings. "Published after 2020" is + * `gt`, "due before Friday" is `lt`. This is why there is no separate + * before/after/on: they are these three under other names. + * · `contains` / `notContains` — text, and anything with a readable form. + * Matches the text a READER SEES (a select's label, a labels list joined), + * case-insensitively, because "contains" is a question about what is on the + * screen and matching a select's internal id would answer a different one. + * · `empty` / `notEmpty` — every type. The one question `is` could never ask: + * an unset value is the absence of a value, not one of its values. + * · `in` — `date` only, and the relative half of this feature: a WINDOW word + * resolved against the reader's today. See `DateWindow`. + * + * Negation is `ne`, `notContains`, `notEmpty` and — for a phase — leaving + * `open` off. There is no `not(...)` wrapper, because a wrapper is nesting. + */ +export type QueryOp = + | 'eq' | 'ne' + | 'gt' | 'gte' | 'lt' | 'lte' + | 'contains' | 'notContains' + | 'empty' | 'notEmpty' + | 'in' + +/** Operators this build can evaluate. A Set, so a key out of a mailed file can + * never reach a prototype the way a bare object lookup would. */ +const OPS: ReadonlySet = new Set([ + 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'notContains', 'empty', 'notEmpty', 'in', +]) + +/** Operators that need no value — the two that ask about absence. */ +const NULLARY: ReadonlySet = new Set(['empty', 'notEmpty']) + +/** + * The relative windows, for `in`. + * + * Five words and no arithmetic in the file. "Overdue" is `past` on a due date; + * "this week" is `week`. A stored `{ op:'lt', v:'2026-09-10' }` would answer + * the question ONCE, on the day it was written, and be wrong every day after — + * which is the whole reason a relative window is a stored WORD and the dates + * are computed at read time. + */ +export type DateWindow = 'today' | 'week' | 'month' | 'past' | 'future' + +const WINDOWS: ReadonlySet = new Set(['today', 'week', 'month', 'past', 'future']) + +/** One condition. `v` is absent exactly for `empty`/`notEmpty`. */ +export interface Clause { + /** a field key from `doc.fields`, or `:title` — see `PAGE_KEYS` */ + key: string + op: QueryOp + v?: string | number +} + +/** + * Keys that are about the PAGE rather than one of its fields. + * + * A colon prefix, because a field key is minted from a label and can never + * start with one — so `:title` cannot collide with a schema somebody writes, + * today or in ten years. Exactly one for now: "title contains X" is the + * question this whole feature was asked for and the title is not a prop block, + * so no field key could ever reach it. + */ +export const PAGE_KEYS: ReadonlySet = new Set([':title']) + +/** What `:title` is called in the field picker. A literal t(), at the call + * site — never a map of English read back, which reaches no catalog. */ +export const pageKeyLabel = (key: string): string => (key === ':title' ? t('Title') : key) + +/** + * What an operator is called to somebody choosing one. + * + * A FUNCTION with literal `t()` calls, exactly like `fieldTypeLabel` and for + * exactly the reason written there: the extractor sweeps LITERALS, so a map of + * English read back through `t(MAP[op])` compiles, runs, reaches no catalog, + * and the packer still reports 100% because it counts what it swept. + */ +export function opLabel(op: string): string { + switch (op) { + case 'eq': return t('is') + case 'ne': return t('is not') + case 'gt': return t('is after') + case 'gte': return t('is on or after') + case 'lt': return t('is before') + case 'lte': return t('is on or before') + case 'contains': return t('contains') + case 'notContains': return t('does not contain') + case 'empty': return t('is empty') + case 'notEmpty': return t('is not empty') + case 'in': return t('is within') + default: return op + } +} + +/** The same words for a number, where "after" reads as nonsense. */ +export function numberOpLabel(op: string): string { + switch (op) { + case 'gt': return t('is more than') + case 'gte': return t('is at least') + case 'lt': return t('is less than') + case 'lte': return t('is at most') + default: return opLabel(op) + } +} + +/** What a window is called. Literal t() at the call site, same rule. */ +export function windowLabel(w: string): string { + switch (w) { + case 'today': return t('Today') + case 'week': return t('This week') + case 'month': return t('This month') + case 'past': return t('In the past') + case 'future': return t('In the future') + default: return w + } +} + +/** + * The operators worth offering for a field, in the order the picker shows them. + * + * Offered, not enforced: a clause naming an operator this type does not list + * still EVALUATES (a `gt` on a text field compares strings and answers + * honestly). Narrowing what a picker offers is a kindness to the person + * building a filter; narrowing what the engine will run would make a filter + * written by a newer build — or by an agent — stop selecting its own rows. + */ +export function opsFor(vt: string | undefined): QueryOp[] { + switch (vt) { + case 'number': return ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'] + case 'date': return ['in', 'eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'empty', 'notEmpty'] + case 'select': return ['eq', 'ne', 'contains', 'empty', 'notEmpty'] + case 'labels': return ['eq', 'ne', 'contains', 'notContains', 'empty', 'notEmpty'] + default: return ['contains', 'notContains', 'eq', 'ne', 'empty', 'notEmpty'] + } +} + +/** Every window, in picker order. */ +export const DATE_WINDOWS: DateWindow[] = ['today', 'week', 'month', 'past', 'future'] + +// --------------------------------------------------------------------------- +// reading a filter safely +// --------------------------------------------------------------------------- +// +// Everything below takes `unknown`. A filter arrives inside a document +// somebody mailed you: `where` can be a string, a clause can be null, `op` can +// be a number, and none of that may throw out of a render. + +/** The clauses this build can see — shape-checked, nothing else. */ +export function clausesOf(filter: unknown): Clause[] { + if (!filter || typeof filter !== 'object') return [] + const raw = (filter as { where?: unknown }).where + if (!Array.isArray(raw)) return [] + const out: Clause[] = [] + for (const c of raw) { + if (!c || typeof c !== 'object') continue + const key = (c as { key?: unknown }).key + const op = (c as { op?: unknown }).op + if (typeof key !== 'string' || !key || typeof op !== 'string' || !op) continue + const v = (c as { v?: unknown }).v + out.push({ key, op: op as QueryOp, v: typeof v === 'string' || typeof v === 'number' ? v : undefined }) + } + return out +} + +/** Is this filter's clause list ORed rather than ANDed? */ +export const isAny = (filter: unknown): boolean => + !!(filter && typeof filter === 'object' && (filter as { any?: unknown }).any === true) + +/** + * Operators a NEWER build wrote and this one cannot evaluate. + * + * The sibling of `unknownFilterKeys`, one level down, and it exists for the + * same reason: a rule that was not applied means the view shows more than its + * author asked for, and a count silently too high is the failure additivity + * trades for. Deduplicated and in file order, so the banner can name them. + */ +export function unknownFilterOps(filter: unknown): string[] { + const out: string[] = [] + for (const c of clausesOf(filter)) { + if (!OPS.has(c.op) && !out.includes(c.op)) out.push(c.op) + // an `in` whose window word is newer is the same failure wearing a known + // operator's clothes, so it is reported the same way + else if (c.op === 'in' && !WINDOWS.has(String(c.v)) && !out.includes(`in:${String(c.v)}`)) { + out.push(`in:${String(c.v)}`) + } + } + return out +} + +/** Clauses that narrow something — what the Filter chip counts. */ +export const clauseCount = (filter: unknown): number => clausesOf(filter).filter(usable).length + +/** + * Does this clause constrain anything at all? + * + * A clause missing its value is the `is: []` case one level down: a half-built + * condition sitting in a popover must not empty the board for a reason nobody + * can see. It is not counted and it is not applied. + */ +function usable(c: Clause): boolean { + if (!OPS.has(c.op)) return false + if (NULLARY.has(c.op)) return true + return c.v !== undefined && c.v !== '' +} + +/** + * Which clauses the engine LOOKS AT — a wider set than the one the chip counts, + * and the difference is load-bearing. + * + * A clause with an operator from a newer build narrows nothing HERE, so it is + * not counted. But dropping it before evaluation is not the same as evaluating + * it to "cannot say": under `any` a dropped clause is one fewer way through the + * OR, so the view would show FEWER rows because of a rule nobody can read — + * the exact failure the whole unknown-operator policy exists to prevent. It + * stays in the list and `clausePasses` answers `undefined` for it. + */ +const applies = (c: Clause): boolean => !OPS.has(c.op) || usable(c) + +// --------------------------------------------------------------------------- +// dates, in the reader's own timezone +// --------------------------------------------------------------------------- + +/** + * The day the week starts on for this reader — 0 Sunday … 6 Saturday. + * + * LOCALE-DEPENDENT AND VIEWER-SCOPED, never in the document. "This week" means + * Monday–Sunday in Berlin and Sunday–Saturday in Chicago, and the same file + * opened in both places should answer each reader's question — the same rule + * the whole app already follows for language, date formatting and journal + * labels (PLATFORM §8). Storing a week-start on the filter would freeze one + * reader's calendar into everybody else's file. + * + * `Intl.Locale.weekInfo` is the browser's own answer where it exists; MONDAY + * (ISO 8601) is the fallback, which is the majority answer and the one the ISO + * date this field already stores agrees with. + */ +export function weekStartDay(locale?: string): number { + try { + const L = new Intl.Locale(locale || (typeof navigator !== 'undefined' ? navigator.language : 'en')) + // weekInfo is a getter on some engines and a method on older ones + const info = (L as unknown as { weekInfo?: { firstDay?: number }; getWeekInfo?: () => { firstDay?: number } }) + const first = (typeof info.getWeekInfo === 'function' ? info.getWeekInfo() : info.weekInfo)?.firstDay + // Intl numbers Monday 1 … Sunday 7; JS numbers Sunday 0 … Saturday 6 + if (typeof first === 'number' && first >= 1 && first <= 7) return first % 7 + } catch { /* an engine without weekInfo, or a locale it will not parse */ } + return 1 +} + +/** An inclusive ISO range. An absent end is unbounded on that side. */ +export interface DateRange { from?: string; to?: string } + +/** + * What a window means TODAY, in local calendar terms. + * + * `today` is injected rather than read, so the rig is not at the mercy of a + * clock — the same arrangement `CalcCtx.today` already uses. + */ +export function windowRange(w: string, today = todayISO(), locale?: string): DateRange | undefined { + switch (w) { + case 'today': return { from: today, to: today } + case 'past': return { to: stepDay(today, -1) } + case 'future': return { from: stepDay(today, 1) } + case 'week': { + const [y, m, d] = today.split('-').map(Number) + // getDay() on a component-built local Date: no parsing, no UTC midnight + const dow = new Date(y, m - 1, d).getDay() + const back = (dow - weekStartDay(locale) + 7) % 7 + const from = stepDay(today, -back) + return { from, to: stepDay(from, 6) } + } + case 'month': { + const [y, m] = today.split('-').map(Number) + const first = `${y}-${String(m).padStart(2, '0')}-01` + // day 0 of the NEXT month is the last day of this one — the constructor + // does month lengths and leap years, which is the one piece of date work + // worth delegating + const last = new Date(y, m, 0) + return { from: first, to: `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}` } + } + default: return undefined + } +} + +// --------------------------------------------------------------------------- +// evaluating one clause +// --------------------------------------------------------------------------- + +const isEmptyValue = (v: unknown): boolean => + v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length) + +/** The text a READER sees for a value — what `contains` is a question about. */ +export function readableOf(f: FieldSpec | undefined, v: unknown): string { + if (Array.isArray(v)) return v.map((x) => optionOf(f, x)?.label ?? String(x)).join(', ') + if (isEmptyValue(v)) return '' + return optionOf(f, v)?.label ?? String(v) +} + +/** The stored forms of a value, for exact match. One entry, or one per member. */ +const storedOf = (v: unknown): string[] => + Array.isArray(v) ? v.map(String) : isEmptyValue(v) ? [] : [String(v)] + +/** + * Compare a value with a clause's `v`, numerically where the field says so. + * + * Returns undefined when the comparison cannot be made — a `gt` against a + * value that is not a number. That is a real "no", not an unknown: "estimate + * is more than 3" is false for an issue whose estimate is the word "big". + */ +function cmp(vt: string | undefined, value: unknown, want: string | number): number | undefined { + if (isEmptyValue(value)) return undefined + if (vt === 'number') { + const a = Number(Array.isArray(value) ? NaN : value), b = Number(want) + if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined + return a < b ? -1 : a > b ? 1 : 0 + } + // Dates included, ON PURPOSE: a `date` field holds `YYYY-MM-DD`, whose string + // order IS its chronological order, in every timezone, with no Date object + // anywhere near it. + const a = String(Array.isArray(value) ? value.join(', ') : value) + const b = String(want) + return a < b ? -1 : a > b ? 1 : 0 +} + +/** + * Does one value satisfy one clause? + * + * `undefined` means THIS BUILD CANNOT SAY — an unknown operator or an unknown + * window word — which the caller turns into "no constraint" under AND and into + * "passes" under OR. Both are the same direction: show more, never less, and + * let the banner say a rule was not applied. + */ +export function clausePasses( + doc: SpacesDoc, values: Map, c: Clause, page?: Page, today?: string, +): boolean | undefined { + if (!OPS.has(c.op)) return undefined + const field = PAGE_KEYS.has(c.key) ? undefined : fieldByKey(doc, c.key) + const value = PAGE_KEYS.has(c.key) + ? (c.key === ':title' ? (page?.title ?? '') : undefined) + : values.get(c.key) + + if (c.op === 'empty') return isEmptyValue(value) + if (c.op === 'notEmpty') return !isEmptyValue(value) + // A half-built condition is already filtered out of the list by `usable`, so + // this is unreachable through `passesClauses` — it is here for the direct + // caller, since this function is exported and a UI previewing a clause as it + // is typed will ask about one before it has a value. + if (c.v === undefined || c.v === '') return undefined + + switch (c.op) { + case 'eq': return storedOf(value).includes(String(c.v)) + case 'ne': return !storedOf(value).includes(String(c.v)) + case 'contains': + return readableOf(field, value).toLowerCase().includes(String(c.v).toLowerCase()) + case 'notContains': + return !readableOf(field, value).toLowerCase().includes(String(c.v).toLowerCase()) + case 'in': { + const r = windowRange(String(c.v), today ?? todayISO()) + if (!r) return undefined // a window word from a newer build + if (isEmptyValue(value)) return false // an unset date is in no window + const s = String(value) + if (r.from && s < r.from) return false + if (r.to && s > r.to) return false + return true + } + default: { + const d = cmp(field?.vt, value, c.v) + if (d === undefined) return false + return c.op === 'gt' ? d > 0 : c.op === 'gte' ? d >= 0 : c.op === 'lt' ? d < 0 : d <= 0 + } + } +} + +/** + * Does a row pass a filter's clause list? + * + * TRUE when there is nothing to apply — an absent `where`, an empty one, or a + * list of half-built clauses — because that is the same rule the rest of this + * filter follows: absent means everything, and so does a constraint that + * constrains nothing. + */ +export function passesClauses( + doc: SpacesDoc, values: Map, filter: unknown, page?: Page, today?: string, +): boolean { + const list = clausesOf(filter).filter(applies) + if (!list.length) return true + if (isAny(filter)) { + // an unknown clause PASSES here: skipping it would leave fewer ways + // through an OR, which is the one direction this file never goes + return list.some((c) => clausePasses(doc, values, c, page, today) !== false) + } + return list.every((c) => clausePasses(doc, values, c, page, today) !== false) +} + +/** + * A clause in words, for the popover's list of what is applied. + * + * A TRANSLATABLE TEMPLATE rather than a hardcoded join, and that is the whole + * point of it being a t() string. `${name} ${op} ${value}` in source would pin + * English word order into every language: Japanese wants the operand before the + * predicate ("Year が 2020 より大きい"), and a fixed join can only ever produce + * the English one. The parts are t()'d at their own call sites and the ORDER is + * the catalogs' to choose. + */ +export function clauseSummary(doc: SpacesDoc, c: Clause): string { + const f = PAGE_KEYS.has(c.key) ? undefined : fieldByKey(doc, c.key) + const field = f ? f.label : PAGE_KEYS.has(c.key) ? pageKeyLabel(c.key) : c.key + const op = f?.vt === 'number' ? numberOpLabel(c.op) : opLabel(c.op) + if (NULLARY.has(c.op)) return t('{field} {op}', { field, op }) + const value = c.op === 'in' ? windowLabel(String(c.v)) + : f?.options?.length ? (optionOf(f, c.v)?.label ?? String(c.v ?? '')) + : String(c.v ?? '') + return t('{field} {op} {value}', { field, op, value }) +} diff --git a/spaces/src/render.ts b/spaces/src/render.ts index 8d5c05d3..496c1b18 100644 --- a/spaces/src/render.ts +++ b/spaces/src/render.ts @@ -20,6 +20,7 @@ import { sortRows, unknownSortKeys, sortDirOf, layoutOf, nextLayout, type ViewSort, type FieldSpec, type ViewLayout, } from './fields' +import { unknownFilterOps } from './query.ts' import { answer, feed, freshContext, type CalcCtx } from './calc.ts' import { ICONS, type IconName } from './icons' import { renderCanvasHead, placeCard } from './canvas.ts' @@ -1087,7 +1088,7 @@ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpt const filter = (b as { filter?: unknown }).filter const sort = (b as { sort?: unknown }).sort const all = viewRows(doc, (b as { source?: unknown }).source) - const rows = sortRows(doc, all.filter((r) => passesFilter(doc, r.values, filter)), sort) + const rows = sortRows(doc, all.filter((r) => passesFilter(doc, r.values, filter, r.page)), sort) const head = document.createElement('div') head.className = 'sp-view-head' @@ -1194,7 +1195,9 @@ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpt // A rule this build cannot evaluate means the view shows MORE than its author // asked for. Additivity keeps the rule; honesty says so. - const unknown = unknownFilterKeys(filter) + // ...and the same rule one level down: an OPERATOR from a newer build is a + // rule that was not applied, which is the same superset with the same banner. + const unknown = [...unknownFilterKeys(filter), ...unknownFilterOps(filter)] if (unknown.length) { const note = document.createElement('p') note.className = 'sp-view-empty'