记忆台
← 返回 app.py 27.1K · 5 天前
\n\"\"\"\n\n\ndef home_body():\n cards = []\n for label, rel, kind in WHITELIST:\n full = os.path.join(HB, rel)\n if kind == \"file\":\n if not os.path.exists(full):\n continue\n st = os.stat(full)\n text = \"\"\n try:\n text = open(full, encoding=\"utf-8\").read()\n except Exception:\n pass\n cards.append(f\"\"\"\n
\n
\ud83d\udcc4 {html.escape(label)}
\n
{human_size(st.st_size)}{len(text.splitlines())} \u884c\n {rel_time(st.st_mtime)}\u6539\u8fc7
\"\"\")\n for label, rel, kind in WHITELIST:\n full = os.path.join(HB, rel)\n if kind != \"dir\" or not os.path.isdir(full):\n continue\n n = len([x for x in os.listdir(full) if not x.startswith(\".\")])\n cards.append(f\"\"\"\n
\n
\ud83d\udcc1 {html.escape(label)}
\n
{n} \u9879{rel_time(os.path.getmtime(full))}\u52a8\u8fc7
\"\"\")\n # recent_memory \u516d\u4e2a\u5939\u7684\u5feb\u6377\u80f6\u56ca\n chips = []\n rm = os.path.join(HB, \"recent_memory\")\n if os.path.isdir(rm):\n for name in sorted(os.listdir(rm)):\n p = os.path.join(rm, name)\n if os.path.isdir(p) and not name.startswith(\".\"):\n rel = f\"recent_memory/{name}\"\n chips.append(f'{html.escape(name)}')\n return f\"\"\"

\u6539\u54ea\u4e00\u4efd\u55b5

\n
\u53ea\u5f00\u8fd9\u51e0\u5904\uff1aSOUL\u3001\u4e24\u683c\u8bb0\u5fc6\u3001recent_memory\u3001skills\u3002\u6539\u5b8c\u5b58\u76d8\u4f1a\u81ea\u52a8\u7559\u4e00\u4efd\u65e7\u7a3f\u3002
\n
{''.join(cards)}
\n

recent_memory \u5206\u5c42

\n
{''.join(chips)}
\"\"\"\n\n\ndef listing_body(rel):\n full = safe_path(rel)\n if not full or not os.path.isdir(full):\n return \"
\u8fd9\u4e2a\u76ee\u5f55\u4e0d\u5728\u653e\u884c\u533a\u91cc\u55b5
\"\n parts = rel.split(\"/\")\n crumb = ['\u8bb0\u5fc6\u53f0']\n acc = []\n for p in parts:\n acc.append(p)\n crumb.append(\"/\")\n crumb.append(f'{html.escape(p)}')\n entries = []\n try:\n names = os.listdir(full)\n except PermissionError:\n return \"
\u6ca1\u6743\u9650\u8bfb
\"\n dirs = sorted([n for n in names if os.path.isdir(os.path.join(full, n)) and not n.startswith(\".\")])\n files = sorted([n for n in names if os.path.isfile(os.path.join(full, n)) and not n.startswith(\".\")])\n for n in dirs:\n p = os.path.join(full, n)\n relp = f\"{rel}/{n}\"\n entries.append(f\"\"\"\ud83d\udcc1\n {html.escape(n)}\n {rel_time(os.path.getmtime(p))}\"\"\")\n for n in files:\n p = os.path.join(full, n)\n ext = os.path.splitext(n)[1].lower()\n if ext not in TEXT_EXT:\n continue\n entries.append(f\"\"\"\ud83d\udcc4\n {html.escape(n)}\n {human_size(os.path.getsize(p))} \u00b7 {rel_time(os.path.getmtime(p))}\"\"\")\n body = f\"
{''.join(crumb)}
\"\n body += \"\".join(entries) if entries else \"
\u8fd9\u4e2a\u5939\u5b50\u662f\u7a7a\u7684
\"\n return body\n\n\ndef editor_body(rel):\n full = safe_path(rel)\n if not full or not os.path.isfile(full):\n return \"
\u8fd9\u4e2a\u6587\u4ef6\u4e0d\u5728\u653e\u884c\u533a\u91cc\u55b5
\", \"\"\n if os.path.splitext(full)[1].lower() not in TEXT_EXT:\n return \"
\u8fd9\u4e0d\u662f\u6587\u672c\u6587\u4ef6\uff0c\u732b\u732b\u4e0d\u78b0
\", \"\"\n if os.path.getsize(full) > MAX_READ:\n return \"
\u6587\u4ef6\u592a\u5927\uff08\u8d85\u8fc7 2M\uff09\uff0c\u4e0d\u9002\u5408\u5728\u8fd9\u91cc\u6539
\", \"\"\n try:\n text = open(full, encoding=\"utf-8\").read()\n except UnicodeDecodeError:\n return \"
\u4e0d\u662f UTF-8\uff0c\u732b\u732b\u4e0d\u6562\u4e71\u5b58
\", \"\"\n parent = os.path.dirname(rel)\n back = f\"./b?p={parent}\" if parent else \"./\"\n st = os.stat(full)\n data = json.dumps(text)\n hist = json.dumps(list_backups(full), ensure_ascii=False)\n body = f\"\"\"\n
\n \u2190 \u8fd4\u56de\n {html.escape(os.path.basename(rel))}\n \n {human_size(st.st_size)} \u00b7 {rel_time(st.st_mtime)}\n \n \n \n \n \n
\n
\n \n
\n
\n
\n\"\"\"\n return body, text\n\n\nclass Handler(BaseHTTPRequestHandler):\n server_version = \"MemoDesk/1.0\"\n\n def log_message(self, fmt, *args):\n pass\n\n def _send(self, code, body, ctype=MIME):\n raw = body.encode() if isinstance(body, str) else body\n self.send_response(code)\n self.send_header(\"Content-Type\", ctype)\n self.send_header(\"Content-Length\", str(len(raw)))\n self.send_header(\"Cache-Control\", \"no-store\")\n self.end_headers()\n self.wfile.write(raw)\n\n def do_GET(self):\n u = urlparse(self.path)\n qs = parse_qs(u.query)\n if u.path in (\"/\", \"\"):\n return self._send(200, page(\"\u8bb0\u5fc6\u53f0\", home_body()))\n if u.path == \"/b\":\n rel = (qs.get(\"p\") or [\"\"])[0]\n return self._send(200, page(rel or \"\u6d4f\u89c8\", listing_body(rel)))\n if u.path == \"/e\":\n rel = (qs.get(\"p\") or [\"\"])[0]\n body, _ = editor_body(rel)\n return self._send(200, page(os.path.basename(rel) or \"\u7f16\u8f91\", body))\n if u.path == \"/search\":\n q = (qs.get(\"q\") or [\"\"])[0].strip().lower()\n hits = []\n if q:\n for root in ALLOWED_ROOTS:\n for dirpath, dirnames, filenames in os.walk(root):\n dirnames[:] = [d for d in dirnames if not d.startswith(\".\")]\n for f in filenames:\n if q in f.lower():\n full = os.path.join(dirpath, f)\n if os.path.splitext(f)[1].lower() in TEXT_EXT or f.endswith(\".md\"):\n hits.append((rel_of(full), os.path.getmtime(full)))\n if len(hits) > 400:\n break\n hits.sort(key=lambda x: -x[1])\n rows = \"\".join(\n f\"\"\"\ud83d\udcc4\n {html.escape(p)}\n {rel_time(t)}\"\"\" for p, t in hits[:80])\n body = f\"
\u641c\u300c{html.escape(q)}\u300d\u00b7 {len(hits)} \u4e2a\u7ed3\u679c
\" + \\\n (rows or \"
\u6ca1\u627e\u5230
\")\n return self._send(200, page(\"\u641c\u7d22\", body))\n return self._send(404, page(\"\u627e\u4e0d\u5230\", \"
\u6ca1\u6709\u8fd9\u4e2a\u9875\u9762\u55b5
\"))\n\n def do_POST(self):\n u = urlparse(self.path)\n try:\n length = int(self.headers.get(\"Content-Length\", 0))\n payload = json.loads(self.rfile.read(length) or \"{}\")\n except Exception:\n return self._json(400, {\"ok\": False, \"error\": \"\u8bf7\u6c42\u4e0d\u5bf9\"})\n if u.path == \"/api/save\":\n rel = payload.get(\"p\", \"\")\n text = payload.get(\"text\", \"\")\n full = safe_path(rel)\n if not full or not os.path.isfile(full):\n return self._json(403, {\"ok\": False, \"error\": \"\u4e0d\u5728\u653e\u884c\u533a\"})\n if os.path.splitext(full)[1].lower() not in TEXT_EXT:\n return self._json(403, {\"ok\": False, \"error\": \"\u4e0d\u662f\u6587\u672c\u6587\u4ef6\"})\n if len(text.encode()) > MAX_SAVE:\n return self._json(413, {\"ok\": False, \"error\": \"\u5185\u5bb9\u592a\u5927\"})\n try:\n backup_file(full)\n tmp = full + \".tmp-save\"\n with open(tmp, \"w\", encoding=\"utf-8\") as f:\n f.write(text)\n os.replace(tmp, full)\n return self._json(200, {\"ok\": True, \"size\": human_size(os.path.getsize(full))})\n except Exception as e:\n return self._json(500, {\"ok\": False, \"error\": str(e)})\n if u.path == \"/api/restore\":\n rel = payload.get(\"p\", \"\")\n bid = payload.get(\"id\", \"\")\n full = safe_path(rel)\n if not full:\n return self._json(403, {\"ok\": False})\n key = hashlib.sha1(rel_of(full).encode()).hexdigest()[:16]\n src = os.path.join(BACKUP_DIR, key, os.path.basename(bid))\n if not os.path.isfile(src):\n return self._json(404, {\"ok\": False})\n return self._json(200, {\"ok\": True, \"text\": open(src, encoding=\"utf-8\").read()})\n return self._json(404, {\"ok\": False})\n\n def _json(self, code, obj):\n self._send(code, json.dumps(obj, ensure_ascii=False), \"application/json; charset=utf-8\")\n\n\nif __name__ == \"__main__\":\n os.makedirs(BACKUP_DIR, exist_ok=True)\n srv = ThreadingHTTPServer((\"127.0.0.1\", 8090), Handler)\n print(\"memo editor on 127.0.0.1:8090\", flush=True)\n srv.serve_forever()\n"; const ta=document.getElementById('src'), pv=document.getElementById('preview'); ta.value=text; let dirty=false; const dot=document.getElementById('dot'); function refresh(){ pv.innerHTML=mdToHtml(ta.value); } function grow(){ ta.style.height='auto'; ta.style.height=(ta.scrollHeight+4)+'px'; } function markDirty(){ dirty=true; dot.classList.add('on'); document.getElementById('stat').textContent='未保存'; } ta.addEventListener('input',()=>{ markDirty(); grow(); // 草稿:切走 App 或刷新时先落在本机,回来能捡 const DKEY='memodesk-draft:'+PATH; ta.addEventListener('input',()=>{ try{ localStorage.setItem(DKEY, JSON.stringify({t:ta.value,at:Date.now()})); }catch(e){} }); (function(){ let d=null; try{ d=JSON.parse(localStorage.getItem(DKEY)||'null'); }catch(e){} if(d && d.t && d.t!==ta.value){ const bar=document.createElement('div'); bar.style.cssText='margin:10px 0 0;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);font-size:13.5px;display:flex;gap:10px;align-items:center;flex-wrap:wrap'; bar.innerHTML='有个没存的草稿('+new Date(d.at).toLocaleString('zh-CN')+')'; const b1=document.createElement('button'); b1.className='btn'; b1.textContent='捡回来'; b1.onclick=()=>{ ta.value=d.t; markDirty(); grow(); bar.remove(); }; const b2=document.createElement('button'); b2.className='btn ghost'; b2.textContent='丢掉'; b2.onclick=()=>{ localStorage.removeItem(DKEY); bar.remove(); }; bar.appendChild(b1); bar.appendChild(b2); document.getElementById('editor-area').prepend(bar); } })(); }); ta.addEventListener('keydown',e=>{ if(e.key==='Tab'){ e.preventDefault(); const s=ta.selectionStart, en=ta.selectionEnd; ta.value=ta.value.slice(0,s)+' '+ta.value.slice(en); ta.selectionStart=ta.selectionEnd=s+2; markDirty(); grow(); } }); grow(); document.getElementById('toggle').onclick=()=>{ const showing=pv.style.display!=='none'; if(showing){ pv.style.display='none'; ta.style.display='block'; grow(); document.getElementById('toggle').textContent='预览'; } else { refresh(); pv.style.display='block'; ta.style.display='none'; document.getElementById('toggle').textContent='编辑'; } }; function toast(t){ const el=document.getElementById('toast'); el.textContent=t; el.classList.add('show'); setTimeout(()=>el.classList.remove('show'),1600); } async function save(){ const btn=document.getElementById('save'); btn.disabled=true; btn.textContent='存…'; try{ const r=await fetch('./api/save',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({p:PATH,text:ta.value})}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'保存失败'); dirty=false; dot.classList.remove('on'); try{ localStorage.removeItem('memodesk-draft:'+PATH); }catch(e){} document.getElementById('stat').textContent=j.size+' · 刚刚'; btn.textContent='已存'; toast('存好了喵'); setTimeout(()=>btn.textContent='保存',1200); }catch(e){ toast('保存失败:'+e.message); btn.textContent='保存'; } finally{ btn.disabled=false; } } document.getElementById('save').onclick=save; document.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='s'){ e.preventDefault(); save(); } }); window.addEventListener('beforeunload',e=>{ if(dirty){ e.preventDefault(); e.returnValue=''; } }); document.getElementById('history').onclick=async()=>{ const box=document.getElementById('histbox'); const list=JSON.parse([]); if(!list.length){ toast('还没有旧稿'); return; } box.style.display='block'; box.innerHTML='
'+list.map(h=>`
${h.id}${h.when} · ${h.size}
`).join('')+'
'; }; async function restore(id){ if(!confirm('用这份旧稿覆盖当前内容?')) return; const r=await fetch('./api/restore',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({p:PATH,id:id})}); const j=await r.json(); if(j.ok){ ta.value=j.text; dirty=true; toast('已载入旧稿,记得保存'); } else toast('恢复失败'); }