@jordancoin/notioncli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/skill/SKILL.md CHANGED
@@ -61,6 +61,9 @@ notion alias list # Show configured aliases with IDs
61
61
  ```bash
62
62
  notion query tasks # Query all rows
63
63
  notion query tasks --filter Status=Active # Filter by property
64
+ notion query tasks --filter "Count>10" # Greater than
65
+ notion query tasks --filter "Count>=5" --filter "Status!=Draft" # AND filters
66
+ notion query tasks --filter "Due>=today" # Relative dates
64
67
  notion query tasks --sort Date:desc # Sort results
65
68
  notion query tasks --filter Status=Active --limit 10 # Combine options
66
69
  notion query tasks --output csv # CSV output
@@ -69,6 +72,10 @@ notion query tasks --output json # JSON output
69
72
  notion --json query tasks # JSON (shorthand)
70
73
  ```
71
74
 
75
+ **Filter operators:** `=` (equals/contains), `!=` (not equal), `>`, `<`, `>=`, `<=`
76
+ **Relative dates:** `today`, `yesterday`, `tomorrow`, `last_week`, `next_week`
77
+ **Multiple filters** combine as AND.
78
+
72
79
  **Output formats:**
73
80
  - `table` — formatted ASCII table (default)
74
81
  - `csv` — header row + comma-separated values
@@ -78,24 +85,29 @@ notion --json query tasks # JSON (shorthand)
78
85
  ### Creating Pages
79
86
 
80
87
  ```bash
88
+ # Use property names as flags (recommended — reads like English)
89
+ notion add tasks --name "Buy groceries" --status "Todo"
90
+ notion add projects --name "New Feature" --priority "High" --due "2026-03-01"
91
+
92
+ # Or use --prop for programmatic use
81
93
  notion add tasks --prop "Name=Buy groceries" --prop "Status=Todo"
82
- notion add projects --prop "Name=New Feature" --prop "Priority=High" --prop "Due=2026-03-01"
94
+
95
+ # Create with markdown body
96
+ notion add tasks --name "Sprint Notes" --from notes.md
83
97
  ```
84
98
 
85
- Multiple `--prop` flags for multiple properties. Property names are case-insensitive and matched against the database schema.
99
+ Property names from your database schema become CLI flags automatically. Multi-word properties use kebab-case: `--due-date` "Due Date".
86
100
 
87
101
  ### Updating Pages
88
102
 
89
- By page ID:
90
103
  ```bash
91
- notion update <page-id> --prop "Status=Done"
92
- notion update <page-id> --prop "Priority=Low" --prop "Notes=Updated by CLI"
93
- ```
104
+ # Dynamic flags (recommended)
105
+ notion update tasks --filter "Name=Ship feature" --status "Done"
106
+ notion update workouts --filter "Name=LEGS #5" --notes "Great session"
94
107
 
95
- By alias + filter (zero UUIDs):
96
- ```bash
97
- notion update tasks --filter "Name=Ship feature" --prop "Status=Done"
98
- notion update workouts --filter "Name=LEGS #5" --prop "Notes=Great session"
108
+ # Or --prop syntax
109
+ notion update <page-id> --prop "Status=Done"
110
+ notion update tasks --filter "Name=Ship feature" --prop "Status=Done" --prop "Notes=Updated"
99
111
  ```
100
112
 
101
113
  ### Reading Pages & Content
@@ -205,6 +217,28 @@ Supports images, PDFs, text files, documents. MIME types auto-detected from exte
205
217
  notion search "quarterly report" # Search across all pages and databases
206
218
  ```
207
219
 
220
+ ### Import
221
+
222
+ ```bash
223
+ # CSV → database pages (headers become property names)
224
+ notion import data.csv --to tasks
225
+
226
+ # JSON → database pages (array of objects)
227
+ notion import data.json --to tasks
228
+
229
+ # Markdown → page with blocks
230
+ notion import notes.md --to tasks --title "Sprint Notes"
231
+ notion import doc.md --parent <page-id> --title "My Document"
232
+ ```
233
+
234
+ ### Export
235
+
236
+ ```bash
237
+ # Export page content as markdown
238
+ notion export tasks --filter "Name=Sprint Notes"
239
+ notion export <page-id>
240
+ ```
241
+
208
242
  ### JSON Output
209
243
 
210
244
  Add `--json` before any command to get the raw Notion API response:
@@ -2,14 +2,14 @@
2
2
  "id": "notioncli",
3
3
  "name": "notioncli — Notion CLI",
4
4
  "description": "Query databases, manage pages, explore relations, and edit blocks in your Notion workspace from the terminal.",
5
- "longDescription": "A powerful CLI for the Notion API built for both humans and AI agents. Auto-discovers databases and creates aliases so you never type UUIDs. Supports querying, filtering, sorting, creating, updating, deleting pages, comments, and appending content. Works with Notion's 2025 dual-ID API. Zero-UUID workflow via alias + filter on every command.",
5
+ "longDescription": "A powerful CLI for the Notion API built for both humans and AI agents. Auto-discovers databases and creates aliases so you never type UUIDs. Full CRUD: query, add, update, delete, get, search. Relations & rollups, blocks CRUD, multi-workspace profiles, database management (db-create, db-update), file uploads, templates, page moves. Correctly routes property changes through dataSources.update() for the 2025 Notion API. Zero-UUID workflow via alias + filter on every command.",
6
6
  "category": "Productivity",
7
7
  "price": 0,
8
8
  "icon": "📝",
9
9
  "author": "JordanCoin",
10
10
  "authorUrl": "https://github.com/JordanCoin",
11
11
  "repository": "https://github.com/JordanCoin/notioncli",
12
- "version": "1.1.0",
12
+ "version": "1.3.0",
13
13
  "tags": ["notion", "cli", "api", "database", "productivity", "workspace", "automation", "ai-agent"],
14
14
  "requirements": ["Node.js >= 18", "Notion API key (free)"],
15
15
  "optional": []
package/test/unit.test.js CHANGED
@@ -10,7 +10,16 @@ const {
10
10
  pagesToRows,
11
11
  formatCsv,
12
12
  formatYaml,
13
+ parseFilterOperator,
14
+ resolveRelativeDate,
13
15
  buildFilterFromSchema,
16
+ buildCompoundFilter,
17
+ markdownToBlocks,
18
+ parseInlineFormatting,
19
+ blocksToMarkdown,
20
+ parseCsv,
21
+ kebabToProperty,
22
+ extractDynamicProps,
14
23
  UUID_REGEX,
15
24
  } = require('../lib/helpers');
16
25
 
@@ -699,4 +708,387 @@ describe('buildFilterFromSchema', () => {
699
708
  title: { contains: 'a=b=c' },
700
709
  });
701
710
  });
711
+
712
+ // ─── Rich filter operators ─────────────────────────────────────────────────
713
+
714
+ it('builds number greater than filter', () => {
715
+ const result = buildFilterFromSchema(schema, 'Count>100');
716
+ assert.deepEqual(result.filter, {
717
+ property: 'Count',
718
+ number: { greater_than: 100 },
719
+ });
720
+ });
721
+
722
+ it('builds number less than filter', () => {
723
+ const result = buildFilterFromSchema(schema, 'Count<50');
724
+ assert.deepEqual(result.filter, {
725
+ property: 'Count',
726
+ number: { less_than: 50 },
727
+ });
728
+ });
729
+
730
+ it('builds number greater than or equal filter', () => {
731
+ const result = buildFilterFromSchema(schema, 'Count>=10');
732
+ assert.deepEqual(result.filter, {
733
+ property: 'Count',
734
+ number: { greater_than_or_equal_to: 10 },
735
+ });
736
+ });
737
+
738
+ it('builds number less than or equal filter', () => {
739
+ const result = buildFilterFromSchema(schema, 'Count<=99');
740
+ assert.deepEqual(result.filter, {
741
+ property: 'Count',
742
+ number: { less_than_or_equal_to: 99 },
743
+ });
744
+ });
745
+
746
+ it('builds not-equal filter for select', () => {
747
+ const result = buildFilterFromSchema(schema, 'Status!=Draft');
748
+ assert.deepEqual(result.filter, {
749
+ property: 'Status',
750
+ select: { does_not_equal: 'Draft' },
751
+ });
752
+ });
753
+
754
+ it('builds not-equal filter for title', () => {
755
+ const result = buildFilterFromSchema(schema, 'Name!=Untitled');
756
+ assert.deepEqual(result.filter, {
757
+ property: 'Name',
758
+ title: { does_not_contain: 'Untitled' },
759
+ });
760
+ });
761
+
762
+ it('builds date after filter', () => {
763
+ const result = buildFilterFromSchema(schema, 'Due>2024-01-01');
764
+ assert.deepEqual(result.filter, {
765
+ property: 'Due',
766
+ date: { after: '2024-01-01' },
767
+ });
768
+ });
769
+
770
+ it('builds date before filter', () => {
771
+ const result = buildFilterFromSchema(schema, 'Due<2024-12-31');
772
+ assert.deepEqual(result.filter, {
773
+ property: 'Due',
774
+ date: { before: '2024-12-31' },
775
+ });
776
+ });
777
+
778
+ it('builds date on_or_after filter', () => {
779
+ const result = buildFilterFromSchema(schema, 'Due>=2024-06-01');
780
+ assert.deepEqual(result.filter, {
781
+ property: 'Due',
782
+ date: { on_or_after: '2024-06-01' },
783
+ });
784
+ });
785
+ });
786
+
787
+ // ─── parseFilterOperator ─────────────────────────────────────────────────────
788
+
789
+ describe('parseFilterOperator', () => {
790
+ it('parses = operator', () => {
791
+ const result = parseFilterOperator('Name=Hello');
792
+ assert.deepEqual(result, { key: 'Name', operator: '=', value: 'Hello' });
793
+ });
794
+
795
+ it('parses > operator', () => {
796
+ const result = parseFilterOperator('Amount>100');
797
+ assert.deepEqual(result, { key: 'Amount', operator: '>', value: '100' });
798
+ });
799
+
800
+ it('parses >= operator', () => {
801
+ const result = parseFilterOperator('Amount>=50');
802
+ assert.deepEqual(result, { key: 'Amount', operator: '>=', value: '50' });
803
+ });
804
+
805
+ it('parses != operator', () => {
806
+ const result = parseFilterOperator('Status!=Done');
807
+ assert.deepEqual(result, { key: 'Status', operator: '!=', value: 'Done' });
808
+ });
809
+
810
+ it('parses <= operator', () => {
811
+ const result = parseFilterOperator('Day<=14');
812
+ assert.deepEqual(result, { key: 'Day', operator: '<=', value: '14' });
813
+ });
814
+
815
+ it('returns error for missing operator', () => {
816
+ const result = parseFilterOperator('justtext');
817
+ assert.ok(result.error);
818
+ });
819
+ });
820
+
821
+ // ─── resolveRelativeDate ───────────────────────────────────────────────────────
822
+
823
+ describe('resolveRelativeDate', () => {
824
+ it('resolves today', () => {
825
+ const result = resolveRelativeDate('today');
826
+ // Use local date (same as the function does)
827
+ const now = new Date();
828
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
829
+ assert.equal(result, today.toISOString().split('T')[0]);
830
+ });
831
+
832
+ it('resolves yesterday', () => {
833
+ const result = resolveRelativeDate('yesterday');
834
+ const now = new Date();
835
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
836
+ today.setDate(today.getDate() - 1);
837
+ assert.equal(result, today.toISOString().split('T')[0]);
838
+ });
839
+
840
+ it('passes through non-keyword values', () => {
841
+ assert.equal(resolveRelativeDate('2024-06-15'), '2024-06-15');
842
+ });
843
+ });
844
+
845
+ // ─── buildCompoundFilter ───────────────────────────────────────────────────────
846
+
847
+ describe('buildCompoundFilter', () => {
848
+ const schema = {
849
+ name: { type: 'title', name: 'Name' },
850
+ status: { type: 'select', name: 'Status' },
851
+ amount: { type: 'number', name: 'Amount' },
852
+ };
853
+
854
+ it('returns single filter for one entry', () => {
855
+ const result = buildCompoundFilter(schema, ['Status=Active']);
856
+ assert.ok(!result.error);
857
+ assert.deepEqual(result.filter, { property: 'Status', select: { equals: 'Active' } });
858
+ });
859
+
860
+ it('returns AND compound for multiple filters', () => {
861
+ const result = buildCompoundFilter(schema, ['Status=Active', 'Amount>10']);
862
+ assert.ok(!result.error);
863
+ assert.ok(result.filter.and);
864
+ assert.equal(result.filter.and.length, 2);
865
+ assert.deepEqual(result.filter.and[0], { property: 'Status', select: { equals: 'Active' } });
866
+ assert.deepEqual(result.filter.and[1], { property: 'Amount', number: { greater_than: 10 } });
867
+ });
868
+
869
+ it('returns error if any filter is invalid', () => {
870
+ const result = buildCompoundFilter(schema, ['Status=Active', 'bogus']);
871
+ assert.ok(result.error);
872
+ });
873
+ });
874
+
875
+ // ─── markdownToBlocks ──────────────────────────────────────────────────────────
876
+
877
+ describe('markdownToBlocks', () => {
878
+ it('parses headings', () => {
879
+ const blocks = markdownToBlocks('# Title\n## Subtitle\n### Section');
880
+ assert.equal(blocks.length, 3);
881
+ assert.equal(blocks[0].type, 'heading_1');
882
+ assert.equal(blocks[1].type, 'heading_2');
883
+ assert.equal(blocks[2].type, 'heading_3');
884
+ });
885
+
886
+ it('parses paragraphs', () => {
887
+ const blocks = markdownToBlocks('Hello world');
888
+ assert.equal(blocks.length, 1);
889
+ assert.equal(blocks[0].type, 'paragraph');
890
+ assert.equal(blocks[0].paragraph.rich_text[0].text.content, 'Hello world');
891
+ });
892
+
893
+ it('parses bullet lists', () => {
894
+ const blocks = markdownToBlocks('- Item 1\n- Item 2\n* Item 3');
895
+ assert.equal(blocks.length, 3);
896
+ blocks.forEach(b => assert.equal(b.type, 'bulleted_list_item'));
897
+ });
898
+
899
+ it('parses numbered lists', () => {
900
+ const blocks = markdownToBlocks('1. First\n2. Second');
901
+ assert.equal(blocks.length, 2);
902
+ blocks.forEach(b => assert.equal(b.type, 'numbered_list_item'));
903
+ });
904
+
905
+ it('parses code blocks', () => {
906
+ const blocks = markdownToBlocks('```javascript\nconst x = 1;\n```');
907
+ assert.equal(blocks.length, 1);
908
+ assert.equal(blocks[0].type, 'code');
909
+ assert.equal(blocks[0].code.language, 'javascript');
910
+ assert.equal(blocks[0].code.rich_text[0].text.content, 'const x = 1;');
911
+ });
912
+
913
+ it('parses quotes', () => {
914
+ const blocks = markdownToBlocks('> This is a quote');
915
+ assert.equal(blocks[0].type, 'quote');
916
+ });
917
+
918
+ it('parses dividers', () => {
919
+ const blocks = markdownToBlocks('---');
920
+ assert.equal(blocks[0].type, 'divider');
921
+ });
922
+
923
+ it('parses todo items', () => {
924
+ const blocks = markdownToBlocks('- [ ] Not done\n- [x] Done');
925
+ assert.equal(blocks[0].type, 'to_do');
926
+ assert.equal(blocks[0].to_do.checked, false);
927
+ assert.equal(blocks[1].to_do.checked, true);
928
+ });
929
+
930
+ it('skips empty lines', () => {
931
+ const blocks = markdownToBlocks('Line 1\n\nLine 2');
932
+ assert.equal(blocks.length, 2);
933
+ });
934
+ });
935
+
936
+ // ─── parseInlineFormatting ─────────────────────────────────────────────────────
937
+
938
+ describe('parseInlineFormatting', () => {
939
+ it('parses bold text', () => {
940
+ const result = parseInlineFormatting('Hello **bold** world');
941
+ assert.equal(result.length, 3);
942
+ assert.equal(result[1].annotations.bold, true);
943
+ assert.equal(result[1].text.content, 'bold');
944
+ });
945
+
946
+ it('parses italic text', () => {
947
+ const result = parseInlineFormatting('Hello *italic* world');
948
+ assert.equal(result.length, 3);
949
+ assert.equal(result[1].annotations.italic, true);
950
+ });
951
+
952
+ it('parses inline code', () => {
953
+ const result = parseInlineFormatting('Use `notion query` here');
954
+ assert.equal(result.length, 3);
955
+ assert.equal(result[1].annotations.code, true);
956
+ assert.equal(result[1].text.content, 'notion query');
957
+ });
958
+
959
+ it('parses links', () => {
960
+ const result = parseInlineFormatting('Visit [GitHub](https://github.com) now');
961
+ assert.equal(result.length, 3);
962
+ assert.equal(result[1].text.content, 'GitHub');
963
+ assert.equal(result[1].text.link.url, 'https://github.com');
964
+ });
965
+
966
+ it('returns plain text when no formatting', () => {
967
+ const result = parseInlineFormatting('Just plain text');
968
+ assert.equal(result.length, 1);
969
+ assert.equal(result[0].text.content, 'Just plain text');
970
+ });
971
+ });
972
+
973
+ // ─── blocksToMarkdown ──────────────────────────────────────────────────────────
974
+
975
+ describe('blocksToMarkdown', () => {
976
+ it('converts heading blocks', () => {
977
+ const blocks = [
978
+ { type: 'heading_1', heading_1: { rich_text: [{ plain_text: 'Title' }] } },
979
+ { type: 'heading_2', heading_2: { rich_text: [{ plain_text: 'Sub' }] } },
980
+ ];
981
+ const md = blocksToMarkdown(blocks);
982
+ assert.ok(md.includes('# Title'));
983
+ assert.ok(md.includes('## Sub'));
984
+ });
985
+
986
+ it('converts paragraph blocks', () => {
987
+ const blocks = [
988
+ { type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'Hello' }] } },
989
+ ];
990
+ assert.equal(blocksToMarkdown(blocks), 'Hello');
991
+ });
992
+
993
+ it('converts code blocks', () => {
994
+ const blocks = [
995
+ { type: 'code', code: { rich_text: [{ plain_text: 'const x = 1;' }], language: 'js' } },
996
+ ];
997
+ const md = blocksToMarkdown(blocks);
998
+ assert.ok(md.includes('```js'));
999
+ assert.ok(md.includes('const x = 1;'));
1000
+ });
1001
+ });
1002
+
1003
+ // ─── parseCsv ──────────────────────────────────────────────────────────────────
1004
+
1005
+ describe('parseCsv', () => {
1006
+ it('parses simple CSV', () => {
1007
+ const rows = parseCsv('Name,Status\nTask 1,Done\nTask 2,Todo');
1008
+ assert.equal(rows.length, 2);
1009
+ assert.equal(rows[0].Name, 'Task 1');
1010
+ assert.equal(rows[0].Status, 'Done');
1011
+ assert.equal(rows[1].Name, 'Task 2');
1012
+ });
1013
+
1014
+ it('handles quoted fields with commas', () => {
1015
+ const rows = parseCsv('Name,Notes\n"Task, Important","Note, here"');
1016
+ assert.equal(rows.length, 1);
1017
+ assert.equal(rows[0].Name, 'Task, Important');
1018
+ assert.equal(rows[0].Notes, 'Note, here');
1019
+ });
1020
+
1021
+ it('handles escaped quotes', () => {
1022
+ const rows = parseCsv('Name\n"He said ""hello"""');
1023
+ assert.equal(rows[0].Name, 'He said "hello"');
1024
+ });
1025
+
1026
+ it('returns empty for single line', () => {
1027
+ const rows = parseCsv('Name,Status');
1028
+ assert.equal(rows.length, 0);
1029
+ });
1030
+ });
1031
+
1032
+ // ─── kebabToProperty ───────────────────────────────────────────────────────────
1033
+
1034
+ describe('kebabToProperty', () => {
1035
+ const schema = {
1036
+ name: { type: 'title', name: 'Name' },
1037
+ status: { type: 'select', name: 'Status' },
1038
+ 'due date': { type: 'date', name: 'Due Date' },
1039
+ };
1040
+
1041
+ it('matches exact lowercase', () => {
1042
+ const result = kebabToProperty('name', schema);
1043
+ assert.equal(result.name, 'Name');
1044
+ });
1045
+
1046
+ it('matches kebab to space', () => {
1047
+ const result = kebabToProperty('due-date', schema);
1048
+ assert.equal(result.name, 'Due Date');
1049
+ });
1050
+
1051
+ it('returns null for no match', () => {
1052
+ const result = kebabToProperty('nonexistent', schema);
1053
+ assert.equal(result, null);
1054
+ });
1055
+
1056
+ it('strips leading dashes', () => {
1057
+ const result = kebabToProperty('--status', schema);
1058
+ assert.equal(result.name, 'Status');
1059
+ });
1060
+ });
1061
+
1062
+ // ─── extractDynamicProps ───────────────────────────────────────────────────────
1063
+
1064
+ describe('extractDynamicProps', () => {
1065
+ const schema = {
1066
+ name: { type: 'title', name: 'Name' },
1067
+ status: { type: 'select', name: 'Status' },
1068
+ 'due date': { type: 'date', name: 'Due Date' },
1069
+ };
1070
+
1071
+ it('extracts dynamic property flags', () => {
1072
+ const argv = ['node', 'notion', 'add', 'tasks', '--name', 'Ship it', '--status', 'Done'];
1073
+ const result = extractDynamicProps(argv, ['prop', 'from'], schema);
1074
+ assert.deepEqual(result, ['Name=Ship it', 'Status=Done']);
1075
+ });
1076
+
1077
+ it('skips known flags', () => {
1078
+ const argv = ['node', 'notion', 'add', 'tasks', '--prop', 'Name=Hello', '--name', 'World'];
1079
+ const result = extractDynamicProps(argv, ['prop', 'from'], schema);
1080
+ assert.deepEqual(result, ['Name=World']);
1081
+ });
1082
+
1083
+ it('handles kebab-case properties', () => {
1084
+ const argv = ['node', 'notion', 'add', 'tasks', '--due-date', '2024-06-15'];
1085
+ const result = extractDynamicProps(argv, ['prop'], schema);
1086
+ assert.deepEqual(result, ['Due Date=2024-06-15']);
1087
+ });
1088
+
1089
+ it('ignores flags not in schema', () => {
1090
+ const argv = ['node', 'notion', 'add', 'tasks', '--bogus', 'value'];
1091
+ const result = extractDynamicProps(argv, ['prop'], schema);
1092
+ assert.deepEqual(result, []);
1093
+ });
702
1094
  });
@@ -1,32 +0,0 @@
1
- const { Client } = require('@notionhq/client');
2
- const h = require('../lib/helpers');
3
- const notion = new Client({ auth: h.loadConfig(h.getConfigPaths().CONFIG_PATH).apiKey });
4
- const parentId = '302903e2-cff4-8059-b808-e3c953fd7d26';
5
-
6
- (async () => {
7
- const db = await notion.databases.create({
8
- parent: { type: 'page_id', page_id: parentId },
9
- title: [{ text: { content: 'Quick Test DB' } }],
10
- properties: { Name: { title: {} }, Priority: { number: {} } }
11
- });
12
- console.log('DB created:', db.id.slice(0,8));
13
- console.log('DB keys with id:', Object.keys(db).filter(k => k.includes('id')));
14
- console.log('database_id:', db.database_id);
15
-
16
- await new Promise(r => setTimeout(r, 2000));
17
-
18
- for (const pt of ['data_source_id', 'database_id']) {
19
- const pid = pt === 'database_id' && db.database_id ? db.database_id : db.id;
20
- try {
21
- const page = await notion.pages.create({
22
- parent: { type: pt, [pt]: pid },
23
- properties: { Name: { title: [{ text: { content: 'test ' + pt } }] } }
24
- });
25
- console.log(pt, 'WORKS:', page.id.slice(0,8));
26
- await notion.pages.update({ page_id: page.id, archived: true });
27
- } catch(e) { console.log(pt, 'FAILED:', e.code, e.message.slice(0,100)); }
28
- }
29
-
30
- await notion.blocks.delete({ block_id: db.id });
31
- console.log('cleaned');
32
- })();