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
7,488 changes: 6,333 additions & 1,155 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "outliner",
"description": "A cross platform desktop outliner that is built using tauri",
"version": "0.0.5",
"version": "0.0.6",
"author": "Angelo R <me@xangelo.ca>",
"license": "MIT",
"homepage": "https://github.com/AngeloR/outliner",
Expand All @@ -27,10 +27,13 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.5",
"@types/jest": "^29.5.14",
"@types/keyboardjs": "^2.5.0",
"@types/lodash": "^4.14.191",
"@types/luxon": "^3.2.0",
"jest": "^30.2.0",
"serve": "^14.2.0",
"ts-jest": "^29.4.6",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
Expand Down
88 changes: 19 additions & 69 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,8 @@
import { OutlineTree, Outline, RawOutline } from "./lib/outline";
import { ContentNode, IContentNode } from "./lib/contentNode";
import { Outline, RawOutline } from "./lib/outline";
import { slugify } from './lib/string';
import * as _ from 'lodash';
import * as fs from '@tauri-apps/plugin-fs';

type RawOutlineData = {
id: string;
name: string;
created: number;
lastModified: number;
}

type OutlineDataStorage = {
id: string;
version: string;
created: number;
name: string;
tree: OutlineTree;
}
import { parseOpmlToRawOutline, serializeRawOutlineToOpml } from './lib/opml';

export class ApiClient {
dir = fs.BaseDirectory.AppLocalData;
Expand All @@ -27,10 +12,10 @@ export class ApiClient {
}

async createDirStructureIfNotExists() {
if (!await fs.exists('outliner/contentNodes', {
if (!await fs.exists('outliner', {
baseDir: fs.BaseDirectory.AppLocalData
})) {
await fs.mkdir('outliner/contentNodes', {
await fs.mkdir('outliner', {
baseDir: fs.BaseDirectory.AppLocalData,
recursive: true
});
Expand All @@ -43,76 +28,41 @@ export class ApiClient {
});

return files.filter(obj => {
return !obj.isDirectory
return !obj.isDirectory && obj.name?.toLowerCase().endsWith('.opml');
});
}

async loadOutline(outlineName: string): Promise<RawOutline> {
const raw = await fs.readTextFile(`outliner/${slugify(outlineName)}.json`, {
private normalizeOutlineFilename(nameOrFilename: string): string {
const trimmed = nameOrFilename.trim();
if (trimmed.toLowerCase().endsWith('.opml')) {
return trimmed;
}
return `${slugify(trimmed)}.opml`;
}

async loadOutline(outlineNameOrFilename: string): Promise<RawOutline> {
const filename = this.normalizeOutlineFilename(outlineNameOrFilename);
const raw = await fs.readTextFile(`outliner/${filename}`, {
baseDir: fs.BaseDirectory.AppLocalData
});

const rawOutline = JSON.parse(raw) as OutlineDataStorage;

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));

// the first node is always the root
contentNodeIds.shift();

const rawContentNodes = await Promise.allSettled(_.map(contentNodeIds, (id) => {
return fs.readTextFile(`outliner/contentNodes/${id}.json`, {
baseDir: fs.BaseDirectory.AppLocalData
})
}));

return {
id: rawOutline.id,
version: rawOutline.version,
created: rawOutline.created,
name: rawOutline.name,
tree: rawOutline.tree,
contentNodes: _.keyBy(_.map(rawContentNodes, raw => {
if (raw.status === 'fulfilled') {
return ContentNode.Create(JSON.parse(raw.value) as IContentNode)
}
else {
console.log('rejected node', raw.reason);
}
}), n => n.id)
}
return parseOpmlToRawOutline(raw);
}

async saveOutline(outline: Outline) {
await fs.writeTextFile(`outliner/${slugify(outline.data.name)}.json`, JSON.stringify({
id: outline.data.id,
version: outline.data.version,
created: outline.data.created,
name: outline.data.name,
tree: outline.data.tree
}), {
await fs.writeTextFile(`outliner/${slugify(outline.data.name)}.opml`, serializeRawOutlineToOpml(outline.data), {
baseDir: fs.BaseDirectory.AppLocalData,
});
}

async renameOutline(oldName: string, newName: string) {
if (newName.length && oldName !== newName) {
return fs.rename(`outliner/${slugify(oldName)}.json`, `outliner/${slugify(newName)}.json`, {
return fs.rename(`outliner/${slugify(oldName)}.opml`, `outliner/${slugify(newName)}.opml`, {
oldPathBaseDir: fs.BaseDirectory.AppLocalData,
newPathBaseDir: fs.BaseDirectory.AppLocalData,
});
}
}

async saveContentNode(node: ContentNode) {
try {
await fs.writeTextFile(`outliner/contentNodes/${node.id}.json`, JSON.stringify(node.toJson()), {
baseDir: fs.BaseDirectory.AppLocalData
});
} catch (e) {
console.error(e);
}
}

save(outline: Outline) {
if (!this.state.has('saveTimeout')) {
this.state.set('saveTimeout', setTimeout(async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ keyboardJS.withContext('navigation', () => {
if (!res.filename || !res.filename.length) {
return;
}
const raw = await api.loadOutline(res.filename.split('.json')[0])
const raw = await api.loadOutline(res.filename);

outline = new Outline(raw);
outliner().innerHTML = await outline.render();
Expand Down Expand Up @@ -139,7 +139,7 @@ async function main() {
});

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

outline = new Outline(raw);
outliner().innerHTML = await outline.render();
Expand Down
1 change: 0 additions & 1 deletion src/keyboard-shortcuts/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export const archive: KeyEventDefinition = {
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);

api.saveContentNode(node);
api.save(outline);

}
Expand Down
1 change: 0 additions & 1 deletion src/keyboard-shortcuts/enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const enter: KeyEventDefinition = {
}

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

}
Expand Down
4 changes: 2 additions & 2 deletions src/keyboard-shortcuts/escape-editing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export const escapeEditing: KeyEventDefinition = {
contentNode.innerHTML = await outline.renderContent(cursor.getIdOfNode());
outline.renderDates();

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

// reset the doc in search
// search.replace(outline.getContentNode(cursor.getIdOfNode()));
Expand Down
1 change: 0 additions & 1 deletion src/keyboard-shortcuts/t.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export const t: KeyEventDefinition = {
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);

api.saveContentNode(node);
api.save(outline);
}
}
Expand Down
1 change: 0 additions & 1 deletion src/keyboard-shortcuts/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const tab: KeyEventDefinition = {
cursor.get().outerHTML = html;

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

}
Expand Down
79 changes: 79 additions & 0 deletions src/lib/opml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { serializeRawOutlineToOpml, parseOpmlToRawOutline } from './opml';
import { RawOutline } from './outline';
import { ContentNode } from './contentNode';

function makeOutline(): RawOutline {
const rootId = 'e067419a-85c3-422d-b8c4-41690be44500';
const childA = 'e067419a-85c3-422d-b8c4-41690be4450d';
const childB = 'e067419a-85c3-422d-b8c4-41690be4450e';

const a = new ContentNode(childA, 'Hello\n**world**');
a.archived = true;
a.archiveDate = 1700000000000;
a.task = true;
a.completionDate = 1700000001111;

const b = new ContentNode(childB, 'Second');
b.deleted = true;
b.deletedDate = 1700000002222;
b.lastUpdated = 1700000003333;

return {
id: rootId,
version: '0.0.1',
created: 1699999999999,
name: 'Sample Outline',
tree: {
id: rootId,
collapsed: false,
children: [
{ id: childA, collapsed: true, children: [] },
{ id: childB, collapsed: false, children: [] },
],
},
contentNodes: {
[childA]: a,
[childB]: b,
},
};
}

describe('OPML', () => {
test('serializes and parses back preserving structure + metadata', () => {
const original = makeOutline();
const xml = serializeRawOutlineToOpml(original);

expect(xml).toContain('<opml version="2.0">');
expect(xml).toContain('<body>');
expect(xml).toContain('outlinerId');

const parsed = parseOpmlToRawOutline(xml);

expect(parsed.id).toBe(original.id);
expect(parsed.version).toBe(original.version);
// OPML `created` values are RFC822 dates (second-level precision).
expect(parsed.created).toBe(Math.floor(original.created / 1000) * 1000);
expect(parsed.name).toBe(original.name);

expect(parsed.tree.children.map(n => ({ id: n.id, collapsed: n.collapsed }))).toEqual(
original.tree.children.map(n => ({ id: n.id, collapsed: n.collapsed }))
);

const parsedA = parsed.contentNodes[original.tree.children[0].id] as unknown as ContentNode;
expect(parsedA.content).toBe('Hello\n**world**');
expect(parsedA.created).toBe(Math.floor(original.contentNodes[parsedA.id].created / 1000) * 1000);
expect(parsedA.archived).toBe(true);
expect(parsedA.archiveDate).toBe(1700000000000);
expect(parsedA.task).toBe(true);
expect(parsedA.completionDate).toBe(1700000001111);

const parsedB = parsed.contentNodes[original.tree.children[1].id] as unknown as ContentNode;
expect(parsedB.content).toBe('Second');
expect(parsedB.created).toBe(Math.floor(original.contentNodes[parsedB.id].created / 1000) * 1000);
expect(parsedB.deleted).toBe(true);
expect(parsedB.deletedDate).toBe(1700000002222);
expect(parsedB.lastUpdated).toBe(1700000003333);
});
});


Loading
Loading