diff --git a/apps/cory/todo.json b/apps/cory/todo.json
new file mode 100644
index 00000000..5f0bdabc
--- /dev/null
+++ b/apps/cory/todo.json
@@ -0,0 +1 @@
+{"type":"tildefriends-app","files":{"app.js":"&QUR1tKa15B5Or8AfPX/8Zs87teSeX0Mh/HF7PEPBom0=.sha256","index.html":"&QXhwvxhHc9fa8iL6088hGDu9FgWdY7wkXgvU2BMNv0A=.sha256","lit-core.min.js":"&tP9KhbgwF1chFqPtkNZ12Yx9AfkpnSjFiPcX5Pw5J9g=.sha256","script.js":"&9RTYD4Le7b6WvqJ8M2TRhZEM0oo6gRVbquZfbvtVIBQ=.sha256"}}
\ No newline at end of file
diff --git a/apps/cory/todo/app.js b/apps/cory/todo/app.js
new file mode 100644
index 00000000..2553b860
--- /dev/null
+++ b/apps/cory/todo/app.js
@@ -0,0 +1,82 @@
+import * as tfrpc from '/tfrpc.js';
+
+let g_db;
+
+tfrpc.register(async function todo_get_all() {
+ let names = await todo_get_names();
+ let result = [];
+ for (let name of names) {
+ result.push({
+ name: name,
+ items: await todo_get(name),
+ });
+ }
+ return result;
+});
+
+async function todo_get_names() {
+ return JSON.parse((await g_db.get('files')) ?? '[]');
+}
+
+async function todo_add(list) {
+ let exchanged = false;
+ let tries = 10;
+ while (!exchanged && tries-- > 0) {
+ let original = await g_db.get('files');
+ let names = JSON.parse(original ?? '[]');
+ let set = new Set(names);
+ set.add(list);
+ names = JSON.stringify([...set].sort());
+ exchanged = original === names || await g_db.exchange('files', original, names);
+ }
+ return exchanged;
+}
+tfrpc.register(todo_add);
+
+async function todo_remove(list) {
+ let exchanged = false;
+ let tries = 10;
+ while (!exchanged && tries-- > 0) {
+ let original = await g_db.get('files');
+ let names = JSON.parse(original ?? '[]');
+ let set = new Set(names);
+ set.delete(list);
+ names = JSON.stringify([...set].sort());
+ exchanged = original === names || await g_db.exchange('files', original, names);
+ }
+ await g_db.remove('list:' + list);
+ return exchanged;
+}
+tfrpc.register(todo_remove);
+
+tfrpc.register(async function todo_rename(old_name, new_name) {
+ if (await g_db.get('list:' + new_name)) {
+ throw RuntimeError(`${new_name} already exists.`);
+ }
+ let list = await todo_get(old_name);
+ await todo_set(new_name, list);
+ await todo_add(new_name);
+ await todo_remove(old_name);
+});
+
+async function todo_get(list) {
+ try {
+ let value = await g_db.get('list:' + list);
+ return JSON.parse(value ?? '[]');
+ } catch (error) {
+ print(error);
+ return [];
+ }
+}
+
+async function todo_set(list, value) {
+ await g_db.set('list:' + list, JSON.stringify(value));
+}
+tfrpc.register(todo_set);
+
+async function main() {
+ g_db = await database('todo');
+ await app.setDocument(utf8Decode(getFile('index.html')));
+}
+
+main();
\ No newline at end of file
diff --git a/apps/cory/todo/index.html b/apps/cory/todo/index.html
new file mode 100644
index 00000000..605816b8
--- /dev/null
+++ b/apps/cory/todo/index.html
@@ -0,0 +1,11 @@
+
+
+
+ TODO
+
+
+ TODO
+
+
+
+
\ No newline at end of file
diff --git a/apps/cory/todo/lit-core.min.js b/apps/cory/todo/lit-core.min.js
new file mode 100644
index 00000000..f11e9db0
--- /dev/null
+++ b/apps/cory/todo/lit-core.min.js
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+const t=window,i=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s=Symbol(),e=new WeakMap;class o{constructor(t,i,e){if(this._$cssResult$=!0,e!==s)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=i}get styleSheet(){let t=this.i;const s=this.t;if(i&&void 0===t){const i=void 0!==s&&1===s.length;i&&(t=e.get(s)),void 0===t&&((this.i=t=new CSSStyleSheet).replaceSync(this.cssText),i&&e.set(s,t))}return t}toString(){return this.cssText}}const n=t=>new o("string"==typeof t?t:t+"",void 0,s),r=(t,...i)=>{const e=1===t.length?t[0]:i.reduce(((i,s,e)=>i+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[e+1]),t[0]);return new o(e,t,s)},h=(s,e)=>{i?s.adoptedStyleSheets=e.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):e.forEach((i=>{const e=document.createElement("style"),o=t.litNonce;void 0!==o&&e.setAttribute("nonce",o),e.textContent=i.cssText,s.appendChild(e)}))},l=i?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let i="";for(const s of t.cssRules)i+=s.cssText;return n(i)})(t):t
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */;var a;const u=window,c=u.trustedTypes,d=c?c.emptyScript:"",v=u.reactiveElementPolyfillSupport,p={toAttribute(t,i){switch(i){case Boolean:t=t?d:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,i){let s=t;switch(i){case Boolean:s=null!==t;break;case Number:s=null===t?null:Number(t);break;case Object:case Array:try{s=JSON.parse(t)}catch(t){s=null}}return s}},f=(t,i)=>i!==t&&(i==i||t==t),m={attribute:!0,type:String,converter:p,reflect:!1,hasChanged:f};class y extends HTMLElement{constructor(){super(),this.o=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this.l=null,this.u()}static addInitializer(t){var i;this.finalize(),(null!==(i=this.v)&&void 0!==i?i:this.v=[]).push(t)}static get observedAttributes(){this.finalize();const t=[];return this.elementProperties.forEach(((i,s)=>{const e=this.p(s,i);void 0!==e&&(this.m.set(e,s),t.push(e))})),t}static createProperty(t,i=m){if(i.state&&(i.attribute=!1),this.finalize(),this.elementProperties.set(t,i),!i.noAccessor&&!this.prototype.hasOwnProperty(t)){const s="symbol"==typeof t?Symbol():"__"+t,e=this.getPropertyDescriptor(t,s,i);void 0!==e&&Object.defineProperty(this.prototype,t,e)}}static getPropertyDescriptor(t,i,s){return{get(){return this[i]},set(e){const o=this[t];this[i]=e,this.requestUpdate(t,o,s)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||m}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const t=Object.getPrototypeOf(this);if(t.finalize(),void 0!==t.v&&(this.v=[...t.v]),this.elementProperties=new Map(t.elementProperties),this.m=new Map,this.hasOwnProperty("properties")){const t=this.properties,i=[...Object.getOwnPropertyNames(t),...Object.getOwnPropertySymbols(t)];for(const s of i)this.createProperty(s,t[s])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){const i=[];if(Array.isArray(t)){const s=new Set(t.flat(1/0).reverse());for(const t of s)i.unshift(l(t))}else void 0!==t&&i.push(l(t));return i}static p(t,i){const s=i.attribute;return!1===s?void 0:"string"==typeof s?s:"string"==typeof t?t.toLowerCase():void 0}u(){var t;this._=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this.g(),this.requestUpdate(),null===(t=this.constructor.v)||void 0===t||t.forEach((t=>t(this)))}addController(t){var i,s;(null!==(i=this.S)&&void 0!==i?i:this.S=[]).push(t),void 0!==this.renderRoot&&this.isConnected&&(null===(s=t.hostConnected)||void 0===s||s.call(t))}removeController(t){var i;null===(i=this.S)||void 0===i||i.splice(this.S.indexOf(t)>>>0,1)}g(){this.constructor.elementProperties.forEach(((t,i)=>{this.hasOwnProperty(i)&&(this.o.set(i,this[i]),delete this[i])}))}createRenderRoot(){var t;const i=null!==(t=this.shadowRoot)&&void 0!==t?t:this.attachShadow(this.constructor.shadowRootOptions);return h(i,this.constructor.elementStyles),i}connectedCallback(){var t;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostConnected)||void 0===i?void 0:i.call(t)}))}enableUpdating(t){}disconnectedCallback(){var t;null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostDisconnected)||void 0===i?void 0:i.call(t)}))}attributeChangedCallback(t,i,s){this._$AK(t,s)}$(t,i,s=m){var e;const o=this.constructor.p(t,s);if(void 0!==o&&!0===s.reflect){const n=(void 0!==(null===(e=s.converter)||void 0===e?void 0:e.toAttribute)?s.converter:p).toAttribute(i,s.type);this.l=t,null==n?this.removeAttribute(o):this.setAttribute(o,n),this.l=null}}_$AK(t,i){var s;const e=this.constructor,o=e.m.get(t);if(void 0!==o&&this.l!==o){const t=e.getPropertyOptions(o),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==(null===(s=t.converter)||void 0===s?void 0:s.fromAttribute)?t.converter:p;this.l=o,this[o]=n.fromAttribute(i,t.type),this.l=null}}requestUpdate(t,i,s){let e=!0;void 0!==t&&(((s=s||this.constructor.getPropertyOptions(t)).hasChanged||f)(this[t],i)?(this._$AL.has(t)||this._$AL.set(t,i),!0===s.reflect&&this.l!==t&&(void 0===this.C&&(this.C=new Map),this.C.set(t,s))):e=!1),!this.isUpdatePending&&e&&(this._=this.T())}async T(){this.isUpdatePending=!0;try{await this._}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this.o&&(this.o.forEach(((t,i)=>this[i]=t)),this.o=void 0);let i=!1;const s=this._$AL;try{i=this.shouldUpdate(s),i?(this.willUpdate(s),null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostUpdate)||void 0===i?void 0:i.call(t)})),this.update(s)):this.P()}catch(t){throw i=!1,this.P(),t}i&&this._$AE(s)}willUpdate(t){}_$AE(t){var i;null===(i=this.S)||void 0===i||i.forEach((t=>{var i;return null===(i=t.hostUpdated)||void 0===i?void 0:i.call(t)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}P(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._}shouldUpdate(t){return!0}update(t){void 0!==this.C&&(this.C.forEach(((t,i)=>this.$(i,this[i],t))),this.C=void 0),this.P()}updated(t){}firstUpdated(t){}}
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+var _;y.finalized=!0,y.elementProperties=new Map,y.elementStyles=[],y.shadowRootOptions={mode:"open"},null==v||v({ReactiveElement:y}),(null!==(a=u.reactiveElementVersions)&&void 0!==a?a:u.reactiveElementVersions=[]).push("1.4.2");const b=window,g=b.trustedTypes,w=g?g.createPolicy("lit-html",{createHTML:t=>t}):void 0,S=`lit$${(Math.random()+"").slice(9)}$`,$="?"+S,C=`<${$}>`,T=document,P=(t="")=>T.createComment(t),x=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,k=t=>A(t)||"function"==typeof(null==t?void 0:t[Symbol.iterator]),E=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,M=/-->/g,U=/>/g,N=RegExp(">|[ \t\n\f\r](?:([^\\s\"'>=/]+)([ \t\n\f\r]*=[ \t\n\f\r]*(?:[^ \t\n\f\r\"'`<>=]|(\"|')|))|$)","g"),R=/'/g,O=/"/g,V=/^(?:script|style|textarea|title)$/i,j=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),z=j(1),L=j(2),I=Symbol.for("lit-noChange"),H=Symbol.for("lit-nothing"),B=new WeakMap,D=T.createTreeWalker(T,129,null,!1),q=(t,i)=>{const s=t.length-1,e=[];let o,n=2===i?"":"");if(!Array.isArray(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==w?w.createHTML(h):h,e]};class J{constructor({strings:t,_$litType$:i},s){let e;this.parts=[];let o=0,n=0;const r=t.length-1,h=this.parts,[l,a]=q(t,i);if(this.el=J.createElement(l,s),D.currentNode=this.el.content,2===i){const t=this.el.content,i=t.firstChild;i.remove(),t.append(...i.childNodes)}for(;null!==(e=D.nextNode())&&h.length0){e.textContent=g?g.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=H}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,i=this,s,e){const o=this.strings;let n=!1;if(void 0===o)t=W(this,t,i,0),n=!x(t)||t!==this._$AH&&t!==I,n&&(this._$AH=t);else{const e=t;let r,h;for(t=o[0],r=0;r{var e,o;const n=null!==(e=null==s?void 0:s.renderBefore)&&void 0!==e?e:i;let r=n._$litPart$;if(void 0===r){const t=null!==(o=null==s?void 0:s.renderBefore)&&void 0!==o?o:null;n._$litPart$=r=new F(i.insertBefore(P(),t),t,void 0,null!=s?s:{})}return r._$AI(t),r};
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */var ot,nt;const rt=y;class ht extends y{constructor(){super(...arguments),this.renderOptions={host:this},this.et=void 0}createRenderRoot(){var t,i;const s=super.createRenderRoot();return null!==(t=(i=this.renderOptions).renderBefore)&&void 0!==t||(i.renderBefore=s.firstChild),s}update(t){const i=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this.et=et(i,this.renderRoot,this.renderOptions)}connectedCallback(){var t;super.connectedCallback(),null===(t=this.et)||void 0===t||t.setConnected(!0)}disconnectedCallback(){var t;super.disconnectedCallback(),null===(t=this.et)||void 0===t||t.setConnected(!1)}render(){return I}}ht.finalized=!0,ht._$litElement$=!0,null===(ot=globalThis.litElementHydrateSupport)||void 0===ot||ot.call(globalThis,{LitElement:ht});const lt=globalThis.litElementPolyfillSupport;null==lt||lt({LitElement:ht});const at={_$AK:(t,i,s)=>{t._$AK(i,s)},_$AL:t=>t._$AL};(null!==(nt=globalThis.litElementVersions)&&void 0!==nt?nt:globalThis.litElementVersions=[]).push("3.2.2");
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+const ut=!1;export{o as CSSResult,ht as LitElement,y as ReactiveElement,rt as UpdatingElement,at as _$LE,it as _$LH,h as adoptStyles,r as css,p as defaultConverter,l as getCompatibleStyle,z as html,ut as isServer,I as noChange,f as notEqual,H as nothing,et as render,i as supportsAdoptingStyleSheets,L as svg,n as unsafeCSS};
+//# sourceMappingURL=lit-core.min.js.map
diff --git a/apps/cory/todo/script.js b/apps/cory/todo/script.js
new file mode 100644
index 00000000..8074b20e
--- /dev/null
+++ b/apps/cory/todo/script.js
@@ -0,0 +1,182 @@
+import {LitElement, html} from './lit-core.min.js';
+import * as tfrpc from '/static/tfrpc.js';
+
+class TodosElement extends LitElement {
+ static get properties() {
+ return {
+ lists: {type: Array}
+ };
+ }
+
+ constructor() {
+ super();
+ this.lists = [];
+ let self = this;
+ tfrpc.rpc.todo_get_all().then(function(lists) {
+ self.lists = lists;
+ }).catch(function(error) {
+ console.log(error);
+ });
+ }
+
+ async new_list() {
+ await tfrpc.rpc.todo_add('new list');
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.lists = await tfrpc.rpc.todo_get_all();
+ }
+
+ render() {
+ return html`
+
+
+ ${this.lists.map(x => html`
+
+ `)}
+
+
+
`;
+ }
+}
+
+class TodoListElement extends LitElement {
+ static get properties() {
+ return {
+ name: {type: String},
+ items: {type: Array},
+ editing: {type: Number},
+ editing_name: {type: Boolean},
+ };
+ }
+
+ constructor() {
+ super();
+ this.items = [];
+ }
+
+ save() {
+ tfrpc.rpc.todo_set(this.name, this.items).catch(function(error) {
+ console.log(error);
+ });
+ }
+
+ remove_item(item) {
+ let index = this.items.indexOf(item);
+ this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1));
+ this.save();
+ }
+
+ handle_check(event, item) {
+ item.x = event.srcElement.checked;
+ this.save();
+ }
+
+ input_blur(item) {
+ this.save();
+ this.editing = undefined;
+ }
+
+ input_change(event, item) {
+ item.text = event.srcElement.value;
+ }
+
+ input_keydown(event, item) {
+ if (event.key === 'Enter' || event.key === 'Escape') {
+ item.text = event.srcElement.value;
+ this.editing = undefined;
+ }
+ }
+
+ updated() {
+ let edit = this.renderRoot.getElementById('edit');
+ if (edit) {
+ edit.select();
+ }
+ }
+
+ render_item(item) {
+ let index = this.items.indexOf(item);
+ let self = this;
+ if (index === this.editing) {
+ return html`
+ self.handle_check(x, item)}>
+ self.input_change(event, item)}
+ @keydown=${event => self.input_keydown(event, item)}
+ @blur=${x => self.input_blur(item)}>
+ self.remove_item(item)}>x
+ `;
+ } else {
+ return html`
+ self.handle_check(x, item)}>
+ self.editing = index}>${item.text}
+ self.remove_item(item)} style="cursor: pointer">❎
+ `;
+ }
+ }
+
+ add_item() {
+ this.items = [].concat(this.items || [], [{text: 'new item'}]);
+ this.editing = this.items.length - 1;
+ this.save();
+ }
+
+ async remove_list() {
+ if (confirm(`Are you sure you want to remove "${this.name}"?`)) {
+ await tfrpc.rpc.todo_remove(this.name);
+ this.dispatchEvent(new Event('change'));
+ }
+ }
+
+ rename(new_name) {
+ let self = this;
+ return tfrpc.rpc.todo_rename(this.name, new_name).then(function() {
+ self.dispatchEvent(new Event('change'));
+ self.editing_name = false;
+ }).catch(function(error) {
+ console.log(error);
+ alert(error.message);
+ self.editing_name = false;
+ });
+ }
+
+ name_blur(new_name) {
+ this.rename(new_name);
+ }
+
+ name_keydown(event, item) {
+ let self = this;
+ if (event.key == 'Enter' || event.key === 'Escape') {
+ let new_name = event.srcElement.value;
+ this.rename(new_name);
+ }
+ }
+
+ render() {
+ let self = this;
+ let name = this.editing_name ?
+ html` self.name_keydown(event)}
+ @blur=${event => self.name_blur(event.srcElement.value)}
+ value=${this.name}>` :
+ html` this.editing_name = true}>${this.name}
`;
+ return html`
+
+ ${name}
+ ${(this.items || []).map(x => self.render_item(x))}
+
+
+
+ `;
+ }
+}
+
+customElements.define('tf-todo-list', TodoListElement);
+customElements.define('tf-todos', TodosElement);
\ No newline at end of file