Skip to content

Commit

Permalink
Add group by field name
Browse files Browse the repository at this point in the history
Fixes #139
  • Loading branch information
jmattheis committed Sep 25, 2020
1 parent 70a23f0 commit e00cbaa
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 31 deletions.
28 changes: 28 additions & 0 deletions changelog/139-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
type: add
issue: 139
audience: user
components:
- ui
- cli
- api
---
# Add groupBy field name to export

## CLI

Via `--group-by` option:

```
$ snage export --group-by type
```

## API

Via `groupBy` query parameter:

`/export?groupBy=type&query=`

## UI

Inside the export dialog.
13 changes: 10 additions & 3 deletions packages/snage/src/command/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,33 @@ import {pipe} from 'fp-ts/lib/pipeable';
import {exportToString} from '../note/export';
import {parseNotes} from '../note/parser';
import {filterNotes} from '../query/filter';
import {groupByFieldNameMaybe} from '../note/group';

export const exportCmd: yargs.CommandModule<DefaultCli, DefaultCli & {condition?: string; tags: boolean}> = {
export const exportCmd: yargs.CommandModule<
DefaultCli,
DefaultCli & {condition?: string; tags: boolean; 'group-by'?: string}
> = {
command: 'export [condition]',
describe: 'Export notes matching [condition]',
builder: (y) =>
y
.example('$0', 'export')
.example('$0', 'export --no-tags "issue = 21"')
.example('$0', 'export --group-by tag "issue = 21"')
.option('tags', {
boolean: true,
description: 'Include note tags',
default: true,
}),
handler: async ({condition = '', tags}) => {
})
.option('group-by', {string: true, description: 'Group notes by a field name'}),
handler: async ({condition = '', tags, 'group-by': groupBy}) => {
const config = getConfigOrExit();
return pipe(
parseNotes(config),
TE.mapLeft((errors) => errors.join('\n')),
TE.chainEitherK(filterNotes(config, condition)),
TE.map(A.sort(config.standard.sort)),
TE.chainEitherK(groupByFieldNameMaybe(config, groupBy)),
TE.fold(T.fromIOK(printAndExit), (notes) =>
T.fromIO<void>(() => {
console.log(exportToString(notes, config.fields, {tags}));
Expand Down
53 changes: 32 additions & 21 deletions packages/snage/src/command/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import yargs from 'yargs';
import express from 'express';
import {getConfig} from '../config/load';
import {parseNotes} from '../note/parser';
import {createParser} from '../query/parser';
import {createParser, Expression, ParseError} from '../query/parser';
import {createMatcher} from '../query/match';
import path from 'path';
import {DefaultCli, printAndExit} from './common';
Expand All @@ -20,6 +20,7 @@ import {collectDefaultMetrics, register} from 'prom-client';
import {startRequestTimer, totalNotes} from '../util/prometheus';
import {exportToString} from '../note/export';
import {LocalDate} from '@js-joda/core';
import {groupByFieldNameMaybe} from '../note/group';

interface Response {
status: number;
Expand Down Expand Up @@ -68,20 +69,18 @@ export const startExpress = (port: number) => ([config, notes]: [Config, Note[]]
const endTimer = startRequestTimer('note');
pipe(
parser(query),
E.bimap(
(e): Response => ({status: 400, body: e}),
(expression) => {
const matcher = createMatcher(expression, config.fields);
return notes.filter(matcher);
}
),
filterNotes(config, notes),
E.map(A.sort(config.standard.sort)),
E.map(A.map((note) => convertToApiNote(note, config.fields))),
E.fold(
identity,
(notes): Response => ({
status: 200,
body: {notes, fieldOrder: config.fields.map((f) => f.name)},
body: {
notes,
fieldOrder: config.fields.map((f) => f.name),
groupByFields: config.fields.filter((field) => !field.list).map((f) => f.name),
},
})
),
({status, body}) => {
Expand All @@ -90,25 +89,25 @@ export const startExpress = (port: number) => ([config, notes]: [Config, Note[]]
}
);
});
app.get('/export', ({query: {query, tags = 'true'}}, res) => {
app.get('/export', ({query: {query, tags = 'true', groupBy}}, res) => {
const endTimer = startRequestTimer('export');
pipe(
parser(query),
E.bimap(
(e): Response => ({status: 400, body: e}),
(expression) => {
const matcher = createMatcher(expression, config.fields);
return notes.filter(matcher);
}
),
filterNotes(config, notes),
E.map(A.sort(config.standard.sort)),
E.chain((notes) =>
E.either.mapLeft(groupByFieldNameMaybe(config, groupBy)(notes), (e) => ({status: 400, body: e}))
),
E.map((notes) => exportToString(notes, config.fields, {tags: tags === 'true'})),
E.fold(identity, (notes): Response => ({status: 200, body: notes})),
({status, body}) => {
res.set('Content-disposition', `attachment; filename=export.${LocalDate.now().toString()}.md`)
.set('Content-Type', 'text/plain')
.status(status)
.send(body);
if (status === 200) {
res.set('Content-disposition', `attachment; filename=export.${LocalDate.now().toString()}.md`).set(
'Content-Type',
'text/plain'
);
}
res.status(status).send(body);
endTimer(status);
}
);
Expand All @@ -123,3 +122,15 @@ export const startExpress = (port: number) => ([config, notes]: [Config, Note[]]

collectDefaultMetrics({prefix: 'snage_'});
};

const filterNotes = (
config: Config,
notes: Note[]
): ((either: E.Either<ParseError, Expression>) => E.Either<Response, Note[]>) =>
E.bimap(
(e) => ({status: 400, body: e}),
(expression) => {
const matcher = createMatcher(expression, config.fields);
return notes.filter(matcher);
}
);
6 changes: 6 additions & 0 deletions packages/snage/src/note/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export const stringEncodeHeader = (fields: ConvertField[], values: NoteValues):
R.filter((v): v is string | string[] => typeof v !== 'undefined')
);

export const encodeStringValue = <Type extends FieldType>(
type: Type,
field: ConvertField,
value: FieldValue
): string | string[] => getIOStringFieldType(type, field).encode(value as never);

export const decodeValue = (field: ConvertField, value: unknown): E.Either<string[], FieldValue> => {
const decoded: E.Either<Errors, FieldValue> = getIOFieldType(field.type, field).decode(value);
return E.either.mapLeft(decoded, stringifyErrors(ReportMode.Simple));
Expand Down
92 changes: 92 additions & 0 deletions packages/snage/src/note/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ const notes: Note[] = [
}),
];

const groupedNotes: Record<string, Note[]> = {
first: [
partialNote({
content: 'my content',
values: {
hello: 'value',
},
file: 'changelog/hello.md',
summary: 'My headline',
}),
partialNote({
content: ' ',
values: {
hello: 'value',
},
file: 'changelog/hello.md',
summary: 'No body',
}),
],
second: [
partialNote({
content: 'is this library?',
values: {
hello: 'other',
},
file: 'changelog/hello.md',
summary: 'cool second',
}),
],
};

describe('export', () => {
test('with tags', () => {
expect(exportToString(notes, fields, {tags: true})).toMatchInlineSnapshot(`
Expand All @@ -52,4 +83,65 @@ describe('export', () => {
# No body"
`);
});
describe('grouped', () => {
test('with tags', () => {
expect(exportToString(groupedNotes, fields, {tags: true})).toMatchInlineSnapshot(`
"##############################
#
# first
#
##############################
---
hello: value
---
# My headline
my content
---
hello: value
---
# No body
##############################
#
# second
#
##############################
---
hello: other
---
# cool second
is this library?"
`);
});
test('without tags', () => {
expect(exportToString(groupedNotes, fields, {tags: false})).toMatchInlineSnapshot(`
"##############################
#
# first
#
##############################
# My headline
my content
# No body
##############################
#
# second
#
##############################
# cool second
is this library?"
`);
});
});
});
23 changes: 22 additions & 1 deletion packages/snage/src/note/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,33 @@ import {toYamlString} from './tostring';
import {encodeHeader} from './convert';
import {Note} from './note';
import {Field} from '../config/type';
import * as R from 'fp-ts/lib/Record';

export interface ExportOptions {
tags: boolean;
}

export const exportToString = (notes: Note[], fields: Field[], {tags}: ExportOptions): string =>
export const exportToString = (
notes: Note[] | Record<string, Note[]>,
fields: Field[],
{tags}: ExportOptions
): string => {
if (Array.isArray(notes)) {
return exportNotes(notes, fields, {tags});
}
return R.toArray(notes)
.map(([key, notes]) => title(key) + '\n\n' + exportNotes(notes, fields, {tags}))
.join('\n\n');
};

const title = (value: string): string => `\
${'#'.repeat(30)}
#
# ${value ? value : '-- no value --'}
#
${'#'.repeat(30)}`;

const exportNotes = (notes: Note[], fields: Field[], {tags}: {tags: boolean}): string =>
notes
.map((note) => {
const content = note.content.trim() === '' ? '' : '\n\n' + note.content.trim();
Expand Down
36 changes: 36 additions & 0 deletions packages/snage/src/note/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {Config} from '../config/type';
import {Note} from './note';
import * as E from 'fp-ts/lib/Either';
import {pipe} from 'fp-ts/lib/pipeable';
import * as A from 'fp-ts/lib/Array';
import {encodeStringValue} from './convert';

export const groupByFieldNameMaybe = (
config: Config,
fieldName?: string
): ((notes: Note[]) => E.Either<string, Record<string, Note[]> | Note[]>) => {
if (fieldName === undefined) {
return E.right;
}
return groupByFieldName(config, fieldName);
};

export const groupByFieldName = (config: Config, fieldName: string) => (
notes: Note[]
): E.Either<string, Record<string, Note[]>> =>
pipe(
config.fields,
A.findFirst(({name}) => name === fieldName),
E.fromOption(() => `field ${fieldName} does not exist`),
E.filterOrElse(
(field) => !field.list,
() => `field ${fieldName} cannot be grouped because it is type list`
),
E.map((field) =>
notes.reduce((grouped, note) => {
const value = note.values[field.name];
const key = value ? encodeStringValue(field.type, field, value).toString() : 'no value';
return {...grouped, [key]: [...(grouped[key] ?? []), note]};
}, {})
)
);
Loading

0 comments on commit e00cbaa

Please sign in to comment.