Skip to content

Commit e746fcd

Browse files
diegorvclaude
andcommitted
feat(properties): underscore relationship keys + Has Many row (TS parity)
Context: completes the underscore-canonical relationship rework on the frontend. The Rust index now reads _belongs_to/_related_to/_has_many and exposes a first-class has_many field; the TS layer mirrors that. Solution: - vault-v2.types.ts: add `hasMany: string[]` to NoteEntryV2 (mirrors the Rust `has_many` field); doc the underscore frontmatter keys; mention has_many in RelationshipBacklinkV2. - PropertiesView.svelte: FIXED_RELATIONSHIPS keys become the canonical underscore form (_belongs_to/_related_to/_has_many); rename the `has` row to `_has_many` labelled "Has Many". The bare label keeps the row human-readable; the property key matches the on-disk frontmatter key. - backlinks.service.ts: doc comment lists the underscore keys + has_many. Behavior: the Properties panel renders Belongs To / Related To / Has Many rows keyed on the underscore frontmatter fields. Collection/portent filters key on _belongs_to/_related_to/_has_many (verified: the query expression parser accepts the leading underscore). Files: - src/lib/types/vault-v2.types.ts: hasMany field + docs (L125-131, L144). - src/lib/features/properties/PropertiesView.svelte: FIXED_RELATIONSHIPS (L49-53). - src/lib/features/backlinks/backlinks.service.ts: doc (L142-143). - src/tests/.../portent-filters.test.ts: _belongs_to/_related_to filters + new _has_many.contains case (L71-100). - src/tests/.../properties.logic.test.ts: formatRelationshipLabel has_many; drop relationship space-alias, add no-alias case (L577-595, L711-722). - src/tests/.../properties.service.test.ts: upsert _belongs_to (L74-80). - src/tests/.../vault-v2.types.test.ts: pin hasMany key (L111-131). - six NoteEntryV2 fixtures: add `hasMany: []`. - tasks/todo/relationship-aliases-has-many.md: Task 3 done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4df6fc8 commit e746fcd

13 files changed

Lines changed: 52 additions & 21 deletions

File tree

src/lib/features/backlinks/backlinks.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export const fetchBacklinksV2 = dedupeInflight(fetchBacklinksV2Inner, (path: str
140140
/**
141141
* Fetches relationship backlinks for a file from the Rust `VaultIndex`.
142142
* These are notes that reference the target via frontmatter fields
143-
* (`belongs_to`, `related_to`, or custom wikilink-bearing fields).
143+
* (`_belongs_to`, `_related_to`, `_has_many`, or custom wikilink-bearing fields).
144144
*/
145145
async function fetchRelationshipBacklinksInner(path: string): Promise<void> {
146146
try {

src/lib/features/properties/PropertiesView.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
4848
const LIFECYCLE_KEYS = new Set(['_favorite', '_organized', '_archived']);
4949
const FIXED_RELATIONSHIPS: { key: string; label: string }[] = [
50-
{ key: 'belongs_to', label: 'Belongs To' },
51-
{ key: 'related_to', label: 'Related To' },
52-
{ key: 'has', label: 'Has' },
50+
{ key: '_belongs_to', label: 'Belongs To' },
51+
{ key: '_related_to', label: 'Related To' },
52+
{ key: '_has_many', label: 'Has Many' },
5353
];
5454
const RELATIONSHIP_KEYS = new Set(FIXED_RELATIONSHIPS.map((r) => r.key));
5555

src/lib/types/vault-v2.types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,12 @@ export interface NoteEntryV2 {
122122
archived: boolean;
123123
/** Lifecycle flag: note is pinned as a favorite. */
124124
favorite: boolean;
125-
/** Hierarchical ownership targets from `belongs_to` frontmatter (wikilink targets). */
125+
/** Hierarchical ownership targets from `_belongs_to` frontmatter (wikilink targets). */
126126
belongsTo: string[];
127-
/** Lateral relationship targets from `related_to` frontmatter (wikilink targets). */
127+
/** Lateral relationship targets from `_related_to` frontmatter (wikilink targets). */
128128
relatedTo: string[];
129+
/** Inverse-ownership targets from `_has_many` frontmatter (wikilink targets). */
130+
hasMany: string[];
129131
/** Generic relationships: field name -> wikilink targets for fields containing `[[...]]`. */
130132
relationships: Record<string, string[]>;
131133
}
@@ -141,7 +143,7 @@ export interface RelationshipBacklinkV2 {
141143
sourcePath: string;
142144
/** Title of the source note. */
143145
sourceName: string;
144-
/** Relationship type (e.g. "belongs_to", "related_to", or custom field name). */
146+
/** Relationship type (e.g. "belongs_to", "related_to", "has_many", or custom field name). */
145147
relationshipType: string;
146148
}
147149

src/tests/fixtures/vault-entries.fixture.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function entryV2(
3535
favorite: false,
3636
belongsTo: [],
3737
relatedTo: [],
38+
hasMany: [],
3839
relationships: {},
3940
...overrides,
4041
};

src/tests/lib/core/filesystem/link-updater.service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function entry(path: string, title?: string): NoteEntryV2 {
4141
favorite: false,
4242
belongsTo: [],
4343
relatedTo: [],
44+
hasMany: [],
4445
relationships: {},
4546
};
4647
}

src/tests/lib/features/backlinks/backlinks.logic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ describe('noteEntryV2ToBacklinkEntry', () => {
195195
favorite: false,
196196
belongsTo: [],
197197
relatedTo: [],
198+
hasMany: [],
198199
relationships: {},
199200
...overrides,
200201
};

src/tests/lib/features/collection/portent-filters.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,33 @@ describe('Portent collection filters', () => {
6868
expect(result.records.map((r) => r.path)).toEqual(['/a.md']);
6969
});
7070

71-
it('filters by belongs_to contains', () => {
71+
it('filters by _belongs_to contains', () => {
7272
const index = makeIndex([
73-
makeRecord('/a.md', { belongs_to: ['project', 'area'] }),
74-
makeRecord('/b.md', { belongs_to: ['other'] }),
73+
makeRecord('/a.md', { _belongs_to: ['project', 'area'] }),
74+
makeRecord('/b.md', { _belongs_to: ['other'] }),
7575
makeRecord('/c.md', {}),
7676
]);
77-
const def = baseDef("belongs_to.contains('project')");
77+
const def = baseDef("_belongs_to.contains('project')");
7878
const result = executeQuery(def, baseView(), index);
7979
expect(result.records.map((r) => r.path)).toEqual(['/a.md']);
8080
});
8181

82-
it('filters by related_to contains', () => {
82+
it('filters by _related_to contains', () => {
8383
const index = makeIndex([
84-
makeRecord('/a.md', { related_to: ['maps'] }),
84+
makeRecord('/a.md', { _related_to: ['maps'] }),
8585
makeRecord('/b.md', {}),
8686
]);
87-
const def = baseDef("related_to.contains('maps')");
87+
const def = baseDef("_related_to.contains('maps')");
88+
const result = executeQuery(def, baseView(), index);
89+
expect(result.records.map((r) => r.path)).toEqual(['/a.md']);
90+
});
91+
92+
it('filters by _has_many contains', () => {
93+
const index = makeIndex([
94+
makeRecord('/a.md', { _has_many: ['task-a', 'task-b'] }),
95+
makeRecord('/b.md', {}),
96+
]);
97+
const def = baseDef("_has_many.contains('task-a')");
8898
const result = executeQuery(def, baseView(), index);
8999
expect(result.records.map((r) => r.path)).toEqual(['/a.md']);
90100
});

src/tests/lib/features/file-icons/icon-resolver.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const makeEntry = (overrides: Partial<NoteEntryV2>): NoteEntryV2 => ({
3939
favorite: false,
4040
belongsTo: [],
4141
relatedTo: [],
42+
hasMany: [],
4243
relationships: {},
4344
...overrides,
4445
});

src/tests/lib/features/properties/lifecycle-filter.logic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function entry(path: string, archived: boolean): NoteEntryV2 {
2626
favorite: false,
2727
belongsTo: [],
2828
relatedTo: [],
29+
hasMany: [],
2930
relationships: {},
3031
};
3132
}

src/tests/lib/features/properties/properties.logic.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,21 @@ describe('frontmatter alias normalization', () => {
575575
});
576576

577577
it('normalizes space-separated aliases', () => {
578-
const content = '---\nis a: place\nbelongs to: geography\n---\n';
578+
const content = '---\nis a: place\nsidebar label: Places\n---\n';
579579
const props = parseFrontmatterProperties(content);
580580
expect(props.find((p) => p.key === 'type')?.value).toBe('place');
581-
expect(props.find((p) => p.key === 'belongs_to')?.value).toBe('geography');
581+
expect(props.find((p) => p.key === '_sidebar_label')?.value).toBe('Places');
582+
});
583+
584+
it('does not alias relationship keys', () => {
585+
// Relationship fields are underscore-canonical and take no alias:
586+
// the bare/space spellings stay verbatim.
587+
const content = '---\nbelongs to: geography\nrelated_to: maps\n---\n';
588+
const props = parseFrontmatterProperties(content);
589+
expect(props.find((p) => p.key === 'belongs_to')).toBeUndefined();
590+
expect(props.find((p) => p.key === '_belongs_to')).toBeUndefined();
591+
expect(props.find((p) => p.key === 'belongs to')?.value).toBe('geography');
592+
expect(props.find((p) => p.key === 'related_to')?.value).toBe('maps');
582593
});
583594

584595
it('normalizes underscore-prefixed system keys', () => {
@@ -711,10 +722,11 @@ describe('computeRemoveRelationshipValue', () => {
711722
describe('formatRelationshipLabel', () => {
712723
it('converts snake_case to Title Case', () => {
713724
expect(formatRelationshipLabel('belongs_to')).toBe('Belongs To');
725+
expect(formatRelationshipLabel('has_many')).toBe('Has Many');
714726
});
715727

716728
it('handles single word', () => {
717-
expect(formatRelationshipLabel('has')).toBe('Has');
729+
expect(formatRelationshipLabel('mentor')).toBe('Mentor');
718730
});
719731

720732
it('handles triple underscore segments', () => {

0 commit comments

Comments
 (0)