Skip to content

Commit f4e1cd6

Browse files
authored
Merge 25fa59a into 0c23001
2 parents 0c23001 + 25fa59a commit f4e1cd6

File tree

12 files changed

+6749
-1235
lines changed

12 files changed

+6749
-1235
lines changed

package-lock.json

Lines changed: 6333 additions & 1155 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "outliner",
33
"description": "A cross platform desktop outliner that is built using tauri",
4-
"version": "0.0.5",
4+
"version": "0.0.6",
55
"author": "Angelo R <me@xangelo.ca>",
66
"license": "MIT",
77
"homepage": "https://github.com/AngeloR/outliner",
@@ -27,10 +27,13 @@
2727
},
2828
"devDependencies": {
2929
"@tauri-apps/cli": "^2.9.5",
30+
"@types/jest": "^29.5.14",
3031
"@types/keyboardjs": "^2.5.0",
3132
"@types/lodash": "^4.14.191",
3233
"@types/luxon": "^3.2.0",
34+
"jest": "^30.2.0",
3335
"serve": "^14.2.0",
36+
"ts-jest": "^29.4.6",
3437
"ts-loader": "^9.4.2",
3538
"ts-node": "^10.9.1",
3639
"tsconfig-paths": "^4.1.2",

src/api.ts

Lines changed: 19 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,8 @@
1-
import { OutlineTree, Outline, RawOutline } from "./lib/outline";
2-
import { ContentNode, IContentNode } from "./lib/contentNode";
1+
import { Outline, RawOutline } from "./lib/outline";
32
import { slugify } from './lib/string';
43
import * as _ from 'lodash';
54
import * as fs from '@tauri-apps/plugin-fs';
6-
7-
type RawOutlineData = {
8-
id: string;
9-
name: string;
10-
created: number;
11-
lastModified: number;
12-
}
13-
14-
type OutlineDataStorage = {
15-
id: string;
16-
version: string;
17-
created: number;
18-
name: string;
19-
tree: OutlineTree;
20-
}
5+
import { parseOpmlToRawOutline, serializeRawOutlineToOpml } from './lib/opml';
216

227
export class ApiClient {
238
dir = fs.BaseDirectory.AppLocalData;
@@ -27,10 +12,10 @@ export class ApiClient {
2712
}
2813

2914
async createDirStructureIfNotExists() {
30-
if (!await fs.exists('outliner/contentNodes', {
15+
if (!await fs.exists('outliner', {
3116
baseDir: fs.BaseDirectory.AppLocalData
3217
})) {
33-
await fs.mkdir('outliner/contentNodes', {
18+
await fs.mkdir('outliner', {
3419
baseDir: fs.BaseDirectory.AppLocalData,
3520
recursive: true
3621
});
@@ -43,76 +28,41 @@ export class ApiClient {
4328
});
4429

4530
return files.filter(obj => {
46-
return !obj.isDirectory
31+
return !obj.isDirectory && obj.name?.toLowerCase().endsWith('.opml');
4732
});
4833
}
4934

50-
async loadOutline(outlineName: string): Promise<RawOutline> {
51-
const raw = await fs.readTextFile(`outliner/${slugify(outlineName)}.json`, {
35+
private normalizeOutlineFilename(nameOrFilename: string): string {
36+
const trimmed = nameOrFilename.trim();
37+
if (trimmed.toLowerCase().endsWith('.opml')) {
38+
return trimmed;
39+
}
40+
return `${slugify(trimmed)}.opml`;
41+
}
42+
43+
async loadOutline(outlineNameOrFilename: string): Promise<RawOutline> {
44+
const filename = this.normalizeOutlineFilename(outlineNameOrFilename);
45+
const raw = await fs.readTextFile(`outliner/${filename}`, {
5246
baseDir: fs.BaseDirectory.AppLocalData
5347
});
54-
55-
const rawOutline = JSON.parse(raw) as OutlineDataStorage;
56-
57-
const contentNodeIds = _.uniq(JSON.stringify(rawOutline.tree).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi));
58-
59-
// the first node is always the root
60-
contentNodeIds.shift();
61-
62-
const rawContentNodes = await Promise.allSettled(_.map(contentNodeIds, (id) => {
63-
return fs.readTextFile(`outliner/contentNodes/${id}.json`, {
64-
baseDir: fs.BaseDirectory.AppLocalData
65-
})
66-
}));
67-
68-
return {
69-
id: rawOutline.id,
70-
version: rawOutline.version,
71-
created: rawOutline.created,
72-
name: rawOutline.name,
73-
tree: rawOutline.tree,
74-
contentNodes: _.keyBy(_.map(rawContentNodes, raw => {
75-
if (raw.status === 'fulfilled') {
76-
return ContentNode.Create(JSON.parse(raw.value) as IContentNode)
77-
}
78-
else {
79-
console.log('rejected node', raw.reason);
80-
}
81-
}), n => n.id)
82-
}
48+
return parseOpmlToRawOutline(raw);
8349
}
8450

8551
async saveOutline(outline: Outline) {
86-
await fs.writeTextFile(`outliner/${slugify(outline.data.name)}.json`, JSON.stringify({
87-
id: outline.data.id,
88-
version: outline.data.version,
89-
created: outline.data.created,
90-
name: outline.data.name,
91-
tree: outline.data.tree
92-
}), {
52+
await fs.writeTextFile(`outliner/${slugify(outline.data.name)}.opml`, serializeRawOutlineToOpml(outline.data), {
9353
baseDir: fs.BaseDirectory.AppLocalData,
9454
});
9555
}
9656

9757
async renameOutline(oldName: string, newName: string) {
9858
if (newName.length && oldName !== newName) {
99-
return fs.rename(`outliner/${slugify(oldName)}.json`, `outliner/${slugify(newName)}.json`, {
59+
return fs.rename(`outliner/${slugify(oldName)}.opml`, `outliner/${slugify(newName)}.opml`, {
10060
oldPathBaseDir: fs.BaseDirectory.AppLocalData,
10161
newPathBaseDir: fs.BaseDirectory.AppLocalData,
10262
});
10363
}
10464
}
10565

106-
async saveContentNode(node: ContentNode) {
107-
try {
108-
await fs.writeTextFile(`outliner/contentNodes/${node.id}.json`, JSON.stringify(node.toJson()), {
109-
baseDir: fs.BaseDirectory.AppLocalData
110-
});
111-
} catch (e) {
112-
console.error(e);
113-
}
114-
}
115-
11666
save(outline: Outline) {
11767
if (!this.state.has('saveTimeout')) {
11868
this.state.set('saveTimeout', setTimeout(async () => {

src/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ keyboardJS.withContext('navigation', () => {
4747
if (!res.filename || !res.filename.length) {
4848
return;
4949
}
50-
const raw = await api.loadOutline(res.filename.split('.json')[0])
50+
const raw = await api.loadOutline(res.filename);
5151

5252
outline = new Outline(raw);
5353
outliner().innerHTML = await outline.render();
@@ -139,7 +139,7 @@ async function main() {
139139
});
140140

141141
modal.on('loadOutline', async filename => {
142-
const raw = await api.loadOutline(filename.split('.json')[0])
142+
const raw = await api.loadOutline(filename)
143143

144144
outline = new Outline(raw);
145145
outliner().innerHTML = await outline.render();

src/keyboard-shortcuts/archive.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ export const archive: KeyEventDefinition = {
7575
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
7676
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);
7777

78-
api.saveContentNode(node);
7978
api.save(outline);
8079

8180
}

src/keyboard-shortcuts/enter.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export const enter: KeyEventDefinition = {
2323
}
2424

2525
cursor.set(`#id-${res.node.id}`);
26-
api.saveContentNode(res.node);
2726
api.save(outline);
2827

2928
}

src/keyboard-shortcuts/escape-editing.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export const escapeEditing: KeyEventDefinition = {
2525
contentNode.innerHTML = await outline.renderContent(cursor.getIdOfNode());
2626
outline.renderDates();
2727

28-
// push the new node content remotely!
29-
api.saveContentNode(outline.getContentNode(cursor.getIdOfNode()));
28+
// persist changes (single-file OPML)
29+
api.save(outline);
3030

3131
// reset the doc in search
3232
// search.replace(outline.getContentNode(cursor.getIdOfNode()));

src/keyboard-shortcuts/t.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export const t: KeyEventDefinition = {
6565
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
6666
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);
6767

68-
api.saveContentNode(node);
6968
api.save(outline);
7069
}
7170
}

src/keyboard-shortcuts/tab.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export const tab: KeyEventDefinition = {
2020
cursor.get().outerHTML = html;
2121

2222
cursor.set(`#id-${res.node.id}`);
23-
api.saveContentNode(res.node);
2423
api.save(outline);
2524

2625
}

src/lib/opml.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { serializeRawOutlineToOpml, parseOpmlToRawOutline } from './opml';
2+
import { RawOutline } from './outline';
3+
import { ContentNode } from './contentNode';
4+
5+
function makeOutline(): RawOutline {
6+
const rootId = 'e067419a-85c3-422d-b8c4-41690be44500';
7+
const childA = 'e067419a-85c3-422d-b8c4-41690be4450d';
8+
const childB = 'e067419a-85c3-422d-b8c4-41690be4450e';
9+
10+
const a = new ContentNode(childA, 'Hello\n**world**');
11+
a.archived = true;
12+
a.archiveDate = 1700000000000;
13+
a.task = true;
14+
a.completionDate = 1700000001111;
15+
16+
const b = new ContentNode(childB, 'Second');
17+
b.deleted = true;
18+
b.deletedDate = 1700000002222;
19+
b.lastUpdated = 1700000003333;
20+
21+
return {
22+
id: rootId,
23+
version: '0.0.1',
24+
created: 1699999999999,
25+
name: 'Sample Outline',
26+
tree: {
27+
id: rootId,
28+
collapsed: false,
29+
children: [
30+
{ id: childA, collapsed: true, children: [] },
31+
{ id: childB, collapsed: false, children: [] },
32+
],
33+
},
34+
contentNodes: {
35+
[childA]: a,
36+
[childB]: b,
37+
},
38+
};
39+
}
40+
41+
describe('OPML', () => {
42+
test('serializes and parses back preserving structure + metadata', () => {
43+
const original = makeOutline();
44+
const xml = serializeRawOutlineToOpml(original);
45+
46+
expect(xml).toContain('<opml version="2.0">');
47+
expect(xml).toContain('<body>');
48+
expect(xml).toContain('outlinerId');
49+
50+
const parsed = parseOpmlToRawOutline(xml);
51+
52+
expect(parsed.id).toBe(original.id);
53+
expect(parsed.version).toBe(original.version);
54+
// OPML `created` values are RFC822 dates (second-level precision).
55+
expect(parsed.created).toBe(Math.floor(original.created / 1000) * 1000);
56+
expect(parsed.name).toBe(original.name);
57+
58+
expect(parsed.tree.children.map(n => ({ id: n.id, collapsed: n.collapsed }))).toEqual(
59+
original.tree.children.map(n => ({ id: n.id, collapsed: n.collapsed }))
60+
);
61+
62+
const parsedA = parsed.contentNodes[original.tree.children[0].id] as unknown as ContentNode;
63+
expect(parsedA.content).toBe('Hello\n**world**');
64+
expect(parsedA.created).toBe(Math.floor(original.contentNodes[parsedA.id].created / 1000) * 1000);
65+
expect(parsedA.archived).toBe(true);
66+
expect(parsedA.archiveDate).toBe(1700000000000);
67+
expect(parsedA.task).toBe(true);
68+
expect(parsedA.completionDate).toBe(1700000001111);
69+
70+
const parsedB = parsed.contentNodes[original.tree.children[1].id] as unknown as ContentNode;
71+
expect(parsedB.content).toBe('Second');
72+
expect(parsedB.created).toBe(Math.floor(original.contentNodes[parsedB.id].created / 1000) * 1000);
73+
expect(parsedB.deleted).toBe(true);
74+
expect(parsedB.deletedDate).toBe(1700000002222);
75+
expect(parsedB.lastUpdated).toBe(1700000003333);
76+
});
77+
});
78+
79+

0 commit comments

Comments
 (0)