Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.55.0",
"version": "2.56.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/cli/commands/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ describe('resolveVaultSelector', () => {
expect(await resolveVaultSelector(personalVault)).toBe(personalVault)
})

it('a vault renamed in the app answers to its display name and to its folder name (#692)', async () => {
await writeConfig({
vaultRoot: workVault,
localVaults: [
{ root: workVault, name: 'Acme API docs', lastOpenedAt: 2 },
{ root: personalVault, name: 'personal', lastOpenedAt: 1 }
]
})
expect(await resolveVaultSelector('acme api docs')).toBe(workVault)
expect(await resolveVaultSelector('Work Vault')).toBe(workVault)
// The display name wins over another vault's folder name.
await fsp.mkdir(path.join(tmpDir, 'Acme API docs'), { recursive: true })
await writeConfig({
vaultRoot: workVault,
localVaults: [
{ root: workVault, name: 'Acme API docs', lastOpenedAt: 2 },
{ root: path.join(tmpDir, 'Acme API docs'), name: 'Other', lastOpenedAt: 1 }
]
})
expect(await resolveVaultSelector('Acme API docs')).toBe(workVault)
})

it('lists known vault names on a bad selector', async () => {
await expect(resolveVaultSelector('nope')).rejects.toThrow(
/No vault named "nope".*Work Vault, personal/
Expand Down
93 changes: 93 additions & 0 deletions apps/desktop/src/main/database-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseRows,
serializeRows,
inferFields,
initialDatabaseContents,
buildDefaultViews,
type GenId
} from '@shared/database-csv'
Expand Down Expand Up @@ -142,6 +143,98 @@ describe('inferFields', () => {
})
})

describe('initialDatabaseContents', () => {
it('without a seed is the empty id + Name grid', () => {
const { sidecar, rows } = initialDatabaseContents(undefined, counterGenId())
expect(rows).toEqual([])
expect(sidecar.fields.map((f) => [f.name, f.type, f.hidden ?? false])).toEqual([
['id', 'text', true],
['Name', 'text', false]
])
expect(sidecar.idFieldId).toBe(sidecar.fields[0].id)
expect(sidecar.views[0].hiddenFieldIds).toEqual([sidecar.idFieldId])
expect(serializeRows(rows, sidecar.fields)).toBe('id,Name\n')
})

it('types a seeded table like an adopted CSV and keys rows by field id (#832)', () => {
const { sidecar, rows } = initialDatabaseContents(
{
headers: ['Task', 'Done', 'Due', 'Hours'],
rows: [
['Write **draft**', 'x', '2026-03-01', '2'],
['Ship, then rest', '', '2026-03-09', '0.5']
]
},
counterGenId()
)
const byName = new Map(sidecar.fields.map((f) => [f.name, f]))
// A hidden id field is synthesized in front of the table's own columns.
expect(sidecar.fields[0].name).toBe('id')
expect(sidecar.fields[0].id).toBe(sidecar.idFieldId)
expect(byName.get('Task')!.type).toBe('text')
expect(byName.get('Done')!.type).toBe('checkbox')
expect(byName.get('Due')!.type).toBe('date')
expect(byName.get('Hours')!.type).toBe('number')
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.id).toBeTruthy()
expect(row.cells[sidecar.idFieldId]).toBe(row.id)
}
expect(rows[0].cells[byName.get('Task')!.id]).toBe('Write **draft**')
expect(rows[1].cells[byName.get('Done')!.id]).toBe('')
// What lands in data.csv: RFC 4180 quoting for the comma, markdown kept verbatim.
const csv = serializeRows(rows, sidecar.fields)
expect(csv.split('\n')[0]).toBe('id,Task,Done,Due,Hours')
expect(csv).toContain(',"Ship, then rest",,2026-03-09,0.5\n')
expect(parseRows(csv, sidecar.fields, sidecar.idFieldId)).toEqual(rows)
})

it('adopts an all-unique id column from the seed instead of synthesizing one', () => {
const { sidecar, rows } = initialDatabaseContents(
{ headers: ['id', 'Name'], rows: [['a1', 'Alpha'], ['b2', 'Beta']] },
counterGenId()
)
expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Name'])
expect(sidecar.fields[0].hidden).toBe(true)
expect(rows.map((r) => r.id)).toEqual(['a1', 'b2'])
})

it('renames blank and duplicate headers and pads short rows', () => {
const { sidecar, rows } = initialDatabaseContents(
{ headers: ['', 'Name', 'Name'], rows: [['only one cell']] },
counterGenId()
)
expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Column 1', 'Name', 'Name (2)'])
const [, c1, n1, n2] = sidecar.fields
expect(rows[0].cells[c1.id]).toBe('only one cell')
expect(rows[0].cells[n1.id]).toBe('')
expect(rows[0].cells[n2.id]).toBe('')
})

it('carries positive column widths onto the matching fields and drops the rest', () => {
const { sidecar } = initialDatabaseContents(
{
headers: ['A', 'B', 'C'],
rows: [],
columnWidths: [120, null, -4]
},
counterGenId()
)
const widths = sidecar.fields.map((f) => f.width)
expect(widths).toEqual([undefined, 120, undefined, undefined])
})

it('a header-only seed makes an empty database with those fields', () => {
const { sidecar, rows } = initialDatabaseContents(
{ headers: ['Title', 'Notes'], rows: [] },
counterGenId()
)
expect(rows).toEqual([])
expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Title', 'Notes'])
expect(sidecar.fields.slice(1).every((f) => f.type === 'text')).toBe(true)
})
})

describe('transforms', () => {
const fields: DbField[] = [
{ id: 'name', name: 'Name', type: 'text' },
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/main/databases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,38 @@ describe('createDatabase + readDatabase', () => {
const b = await createDatabase(root, 'inbox', '', 'Notes')
expect(a.path).not.toBe(b.path)
})

it('writes a seeded database with the seed rows and inferred types (#832)', async () => {
const root = await makeVault()
const doc = await createDatabase(root, 'inbox', 'Work', 'Roadmap', {
headers: ['Item', 'Done', 'Estimate'],
rows: [
['Write "spec", v1', 'yes', '3'],
['Ship', 'no', '1']
],
columnWidths: [240, null, null]
})
expect(doc.path).toBe('inbox/Work/Roadmap.base/data.csv')
expect(doc.fields.map((f) => [f.name, f.type])).toEqual([
['id', 'text'],
['Item', 'text'],
['Done', 'checkbox'],
['Estimate', 'number']
])
expect(doc.fields[1].width).toBe(240)
expect(doc.rows).toHaveLength(2)

const csv = await readFile(path.join(root, doc.path), 'utf8')
const lines = csv.trimEnd().split('\n')
expect(lines[0]).toBe('id,Item,Done,Estimate')
expect(lines[1]).toBe(`${doc.rows[0].id},"Write ""spec"", v1",yes,3`)
expect(lines[2]).toBe(`${doc.rows[1].id},Ship,no,1`)

// Reopening through the sidecar yields the same rows and ids.
const reopened = await readDatabase(root, doc.path)
expect(reopened.rows).toEqual(doc.rows)
expect(reopened.fields).toEqual(doc.fields)
})
})

describe('writeDatabaseRows round-trip', () => {
Expand Down
29 changes: 12 additions & 17 deletions apps/desktop/src/main/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'node:path'
import { randomUUID } from 'node:crypto'
import {
inferFields,
initialDatabaseContents,
buildDefaultViews,
parseCsv,
parseRows,
Expand All @@ -23,6 +24,7 @@ import {
formTitleFromCsvPath,
isFormDirName,
type DatabaseDoc,
type DatabaseSeed,
type DatabaseSidecar,
type DatabaseSummary,
type DbField,
Expand Down Expand Up @@ -250,15 +252,18 @@ export async function writeDatabaseSchema(
}

/**
* Create a new empty database (`id` + `Name` fields) under `folder`/`subpath`
* and return it hydrated. Uses `folderRoot` so a root-mode vault creates at the
* vault root rather than inventing an `inbox/` directory.
* Create a new database under `folder`/`subpath` and return it hydrated: empty
* (`id` + `Name` fields) by default, or holding the `seed`'s columns and rows
* when one is given (a Markdown table converted in place, #832). Uses
* `folderRoot` so a root-mode vault creates at the vault root rather than
* inventing an `inbox/` directory.
*/
export async function createDatabase(
root: string,
folder: NoteFolder,
subpath: string,
title?: string
title?: string,
seed?: DatabaseSeed
): Promise<DatabaseDoc> {
const safeTitle = (title ?? 'Untitled Database').trim() || 'Untitled Database'
const baseName = safeTitle.replace(/[\\/:*?"<>|]/g, '-')
Expand All @@ -281,22 +286,12 @@ export async function createDatabase(
}
const rel = csvPathForFormDir(formDirRel)

const idField: DbField = { id: randomUUID(), name: 'id', type: 'text', hidden: true }
const nameField: DbField = { id: randomUUID(), name: 'Name', type: 'text' }
const fields = [idField, nameField]
const { views, activeViewId } = buildDefaultViews(fields)
const sidecar: DatabaseSidecar = {
version: 1,
idFieldId: idField.id,
fields,
views,
activeViewId
}
const { sidecar, rows } = initialDatabaseContents(seed, randomUUID)
// Create the folder, then the two data files.
await fs.mkdir(databaseDataPath(root, formDirRel), { recursive: true })
await persistSidecar(root, rel, sidecar)
await writeFileAtomic(databaseDataPath(root, rel), serializeRows([], fields))
return hydrate(rel, sidecar, [])
await writeFileAtomic(databaseDataPath(root, rel), serializeRows(rows, sidecar.fields))
return hydrate(rel, sidecar, rows)
}

/**
Expand Down
Loading
Loading