Skip to content

Instantly share code, notes, and snippets.

@rsc
Created November 13, 2019 17:07
Show Gist options
  • Save rsc/9cf2c4f62b36ed8173bb2322226e8772 to your computer and use it in GitHub Desktop.
Save rsc/9cf2c4f62b36ed8173bb2322226e8772 to your computer and use it in GitHub Desktop.
Acme Tiddler client - does not compile!
// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Originally code.google.com/p/rsc/cmd/issue/acme.go,
// then rsc.io/github/issue/acme.go.
// +build !appengine
package main
import (
"bytes"
"flag"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"9fans.net/go/acme"
"9fans.net/go/draw"
)
func acmeMode() {
var dummy awin
dummy.prefix = "/tiddly/" + *project + "/"
if flag.NArg() > 0 {
for _, arg := range flag.Args() {
dummy.look(arg)
}
} else {
dummy.look("$:/StoryList")
}
select {}
}
const (
modeSingle = 1 + iota
modeSearch
modeCreate
)
type awin struct {
*acme.Win
prefix string
mode int
query string
title string
tiddler *Tiddler
}
var all struct {
sync.Mutex
m map[string]*awin
f map[string]*draw.Font
numwin int
}
func (w *awin) exit() {
all.Lock()
defer all.Unlock()
if all.m[w.title] == w {
delete(all.m, w.title)
}
if all.numwin--; all.numwin == 0 {
os.Exit(0)
}
}
func (w *awin) new(title string) *awin {
all.Lock()
defer all.Unlock()
all.numwin++
if all.m == nil {
all.m = make(map[string]*awin)
}
w1 := new(awin)
w1.title = title
var err error
w1.Win, err = acme.New()
if err != nil {
log.Printf("creating acme window: %v", err)
time.Sleep(10 * time.Millisecond)
w1.Win, err = acme.New()
if err != nil {
log.Fatalf("creating acme window again: %v", err)
}
}
w1.prefix = w.prefix
w1.Name(w1.prefix + strings.Replace(title, " ", "␣", -1))
if title != "new" {
all.m[title] = w1
}
return w1
}
func (w *awin) show(title string) *awin {
all.Lock()
defer all.Unlock()
if w1 := all.m[title]; w1 != nil {
w.Ctl("show")
return w1
}
return nil
}
func (w *awin) look(text string) bool {
if w.show(text) != nil {
return true
}
if t, err := Read(text); err == nil {
w.newTiddler(text, t)
return true
}
return false
}
/*
func (w *awin) createIssue() {
w = w.new("new")
w.mode = modeCreate
w.Ctl("cleartag")
w.Fprintf("tag", " Put Search ")
go w.load()
go w.loop()
}
*/
func (w *awin) newTiddler(title string, t *Tiddler) {
w = w.new(title)
w.mode = modeSingle
w.Ctl("cleartag")
w.Fprintf("tag", " Get Put Look ")
go w.load()
go w.loop()
}
func (w *awin) newSearch(title, query string) {
w = w.new(title)
w.mode = modeSearch
w.query = query
w.Ctl("cleartag")
w.Fprintf("tag", " New Get Search ")
w.Write("body", []byte("Loading..."))
go w.load()
go w.loop()
}
func (w *awin) blinker() func() {
c := make(chan struct{})
go func() {
t := time.NewTicker(1000 * time.Millisecond)
defer t.Stop()
dirty := false
for {
select {
case <-t.C:
dirty = !dirty
if dirty {
w.Ctl("dirty")
} else {
w.Ctl("clean")
}
case <-c:
if dirty {
w.Ctl("clean")
}
c <- struct{}{}
return
}
}
}()
return func() {
c <- struct{}{}
<-c
}
}
func (w *awin) clear() {
w.Addr(",")
w.Write("data", nil)
}
func (w *awin) load() {
switch w.mode {
case modeSingle:
stop := w.blinker()
t, err := Read(w.title)
stop()
w.clear()
if err != nil {
w.Write("body", []byte(err.Error()))
break
}
w.Write("body", []byte(t.Text))
w.Ctl("clean")
w.tiddler = t
case modeSearch:
var buf bytes.Buffer
stop := w.blinker()
err := List(&buf, w.query)
stop()
w.clear()
if err != nil {
w.Write("body", []byte(err.Error()))
break
}
w.Write("body", buf.Bytes())
w.Ctl("clean")
}
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func (w *awin) err(s string) {
if !strings.HasSuffix(s, "\n") {
s = s + "\n"
}
w1 := w.show("+Errors")
if w1 == nil {
w1 = w.new("+Errors")
}
w1.Fprintf("body", "%s", s)
w1.Addr("$")
w1.Ctl("dot=addr")
w1.Ctl("show")
}
func diff(line, field, old string) *string {
old = strings.TrimSpace(old)
line = strings.TrimSpace(strings.TrimPrefix(line, field))
if old == line {
return nil
}
return &line
}
func (w *awin) put() {
stop := w.blinker()
defer stop()
switch w.mode {
case modeSingle:
old := w.tiddler
if w.mode == modeCreate {
old = new(Tiddler)
}
data, err := w.ReadAll("body")
if err != nil {
w.err(fmt.Sprintf("Put: %v", err))
return
}
if err := Write(w.title, data, *useMeta); err != nil {
w.err(err.Error())
return
}
w.tiddler = old
w.load()
case modeSearch:
w.err("cannot Put tiddler search list")
}
}
func (w *awin) loadText(e *acme.Event) {
if len(e.Text) == 0 && e.Q0 < e.Q1 {
w.Addr("#%d,#%d", e.Q0, e.Q1)
data, err := w.ReadAll("xdata")
if err != nil {
w.err(err.Error())
}
e.Text = data
}
}
func (w *awin) selection() string {
w.Ctl("addr=dot")
data, err := w.ReadAll("xdata")
if err != nil {
w.err(err.Error())
}
return string(data)
}
func (w *awin) loop() {
defer w.exit()
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X': // execute
cmd := strings.TrimSpace(string(e.Text))
if cmd == "Get" {
w.load()
break
}
if cmd == "Put" {
w.put()
break
}
if cmd == "Del" {
w.Ctl("del")
break
}
if cmd == "New" {
// w.createIssue()
w.err("no new yet")
break
}
if strings.HasPrefix(cmd, "Search ") {
w.newSearch("search", strings.TrimSpace(strings.TrimPrefix(cmd, "Search")))
break
}
w.WriteEvent(e)
case 'l', 'L': // look
// TODO(rsc): Expand selection, especially for links.
w.loadText(e)
if !w.look(string(e.Text)) {
w.WriteEvent(e)
}
}
}
}
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !appengine
/*
Tiddler is a client for reading and writing tiddlers stored on a remote server.
The tiddlers are assumed to be stored on Google Cloud Datastore,
matching the backing store for TiddlyWiki on Google App Engine (see https://github.com/rsc/tiddly/).
usage: tiddler [-a] [-e] [-l] [-m] [-w] [-p project] <title>
By default, tiddler reads the named tiddler and prints it to standard output.
If the -w flag is specified, tiddler instead copies standard input to the named
tiddler.
If the -e flag is specified, tiddler reads the named tiddler, copies it to a
temporary file, opens it in a text editor ($VISUAL if set, $EDITOR if set,
or else ed), waits for the editor to exit, and then writes the tiddler back
to the server.
By default, reading a tiddler does not display its metadata, and writing
a tiddler does not modify any existing user-defined metadata.
If the -m flag is specified, the read, written, or edited tiddler is prefixed
by a metadata header consisting of a formatted JSON dictionary followed by
a blank line. When writing or editing, this header replaces any existing metadata.
If the -a flag is specified, tiddler runs as an acme client; see below.
If the -l flag is specified, tiddler prints a list of tiddler titles, one per line,
limited to those containing <title> as a substring. In this case, the
<title> can be omitted, causing tiddler to print all tiddler titles.
The -p flag specifies the Google Cloud Datastore's project ID.
For example, the TiddlyWiki at tiddlywiki-gae.appspot.com has
project ID tiddlywiki-gae. If the -p flag is not specified, tiddler
uses the environment variable $tiddlycloud.
Authentication
Tiddler expects to be able to use the Google Application Default Credentials
to access the Google Cloud Datastore. Typically this means one must run
“gcloud auth login” before using tiddler.
Acme Editor Integration
Not yet implemented.
*/
package main
/*
If the -a flag is specified, tiddler runs as a collection of acme windows
instead of a command-line tool. In this mode, zero or more tiddler titles can be listed.
If no tiddler is given, tiddler opens the story list tiddler “$:/StoryList”.
If multiple tiddlers are listed, tiddler opens each in a new window.
Each acme window displays a single tiddler.
The title of the window is /tiddly/<project>/<title>, where <project> is
the Google Cloud project ID (omitted if the -p flag was not specified explicitly)
and <title> is the title of the tiddler, with spaces replaced by visible spaces (U+2423, ␣).
Executing "Get" rereads the tiddler.
Executing "List" opens a new acme window showing a list of tiddlers.
If there is an argument, the list is restricted to those whose titles have
the argument as a substring.
Executing "Meta" toggles the display of the metadata header.
Executing "Put" writes the tiddler. It is allowed to execute Put multiple times.
Put will fail if a concurrent edit has been made to the tiddler by another client.
Executing "New <title>" opens a window for the tiddler with the given name.
The tiddler does not exist, it will be created when Put is executed in the window.
Right-clicking text behaves similarly to executing "New", except that it only
has an effect when the named tiddler already exists.
*/
// TODO: Add -d flag to delete tiddler.
// TODO: Update tiddly and this one to store deleted tiddler meta as key TiddlerDeleted instead of Tiddler.
// That will make it cheaper to create lots of Tiddlers and throw them away.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"strings"
"time"
"unicode/utf8"
"cloud.google.com/go/datastore"
)
var (
ctx context.Context
client *datastore.Client
user string
acmeFlag = flag.Bool("a", false, "edit in acme")
editMode = flag.Bool("e", false, "edit in editor")
listMode = flag.Bool("l", false, "list tiddlers")
useMeta = flag.Bool("m", false, "include metadata")
writeMode = flag.Bool("w", false, "write tiddler")
project = flag.String("p", "", "Cloud Datastore project ID (default $tiddlycloud)")
)
func usage() {
fmt.Fprintf(os.Stderr, "usage: tiddler [-a] [-e] [-l] [-m] [-w] [-p project] <title>\n")
flag.PrintDefaults()
os.Exit(2)
}
type Tiddler struct {
Key *datastore.Key `datastore:"__key__"`
Rev int `datastore:"Rev,noindex"`
Meta string `datastore:"Meta,noindex"`
Text string `datastore:"Text,noindex"`
Tags []string
meta map[string]interface{}
}
func main() {
flag.Usage = usage
flag.Parse()
if !*acmeFlag && !*listMode && flag.NArg() != 1 || *listMode && flag.NArg() > 1 {
usage()
}
Init()
if *acmeFlag {
acmeMode()
log.Fatalf("acme not implemented")
}
if *listMode {
pattern := ""
if flag.NArg() == 1 {
pattern = flag.Arg(0)
}
if err := List(os.Stdout, pattern); err != nil {
log.Fatal(err)
}
return
}
title := flag.Arg(0)
if *writeMode {
all, err := ioutil.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("reading input: %v", err)
}
if err := Write(title, all, *useMeta); err != nil {
log.Fatalf("writing tiddler: %v", err)
}
return
}
if *editMode {
t, err := Read(title)
if err != nil && err != ErrNoTiddler {
log.Fatal(err)
}
if t == nil {
t = &Tiddler{Meta: "{}"}
}
var buf bytes.Buffer
if *useMeta {
json.Indent(&buf, []byte(t.Meta), "", " ")
buf.WriteString("\n\n")
}
buf.WriteString(t.Text)
f, err := ioutil.TempFile("", "tiddler-edit-")
if err != nil {
log.Fatal(err)
}
if err := ioutil.WriteFile(f.Name(), buf.Bytes(), 0600); err != nil {
log.Fatal(err)
}
if err := RunEditor(f.Name()); err != nil {
log.Fatal(err)
}
updated, err := ioutil.ReadFile(f.Name())
if err != nil {
log.Fatal(err)
}
name := f.Name()
f.Close()
os.Remove(name)
if bytes.Equal(buf.Bytes(), updated) {
log.Print("no changes")
return
}
if err := Write(title, updated, *useMeta); err != nil {
log.Fatal(err)
}
return
}
t, err := Read(title)
if err != nil {
log.Fatal(err)
}
if t.Meta == "" {
log.Fatal("tiddler has been deleted")
}
if *useMeta {
var buf bytes.Buffer
json.Indent(&buf, []byte(t.Meta), "", " ")
buf.WriteString("\n\n")
os.Stdout.Write(buf.Bytes())
}
os.Stdout.WriteString(t.Text)
}
func Init() {
log.SetPrefix("tiddly: ")
log.SetFlags(0)
var err error
ctx = context.Background()
if *project == "" {
*project = os.Getenv("tiddlycloud")
if *project == "" {
log.Fatalf("must specify -p project or set $tiddlycloud")
}
}
client, err = datastore.NewClient(ctx, *project)
if err != nil {
log.Fatal(err)
}
// Verify that we can communicate and authenticate with the datastore service.
t, err := client.NewTransaction(ctx)
if err != nil {
log.Fatal("cannot connect: %v", err)
}
if err := t.Rollback(); err != nil {
log.Fatal("cannot connect: %v", err)
}
user = os.Getenv("USER")
}
func Key(title string) *datastore.Key {
return datastore.NameKey("Tiddler", title, nil)
}
func HistoryKey(title string, rev int) *datastore.Key {
return datastore.NameKey("TiddlerHistory", title+"#"+fmt.Sprint(rev), nil)
}
func List(w io.Writer, pattern string) error {
q := datastore.NewQuery("Tiddler")
if strings.HasPrefix(pattern, "tag:") {
q = q.Filter("Tags =", pattern[len("tag:"):])
pattern = ""
}
q = q.KeysOnly()
keys, err := client.GetAll(ctx, q, nil)
if err != nil {
return err
}
for _, key := range keys {
if strings.Contains(key.Name, pattern) {
fmt.Fprintf(w, "%s\n", key.Name)
}
}
return nil
}
var ErrNoTiddler = errors.New("no such tiddler")
func Read(title string) (*Tiddler, error) {
var t Tiddler
err := client.Get(ctx, Key(title), &t)
if err == datastore.ErrNoSuchEntity {
return nil, ErrNoTiddler
}
if err != nil {
return nil, err
}
return &t, nil
}
func Now() string {
return strings.Replace(time.Now().UTC().Format("20060102150405.000"), ".", "", -1)
}
func Write(title string, data []byte, meta bool) error {
m := map[string]interface{}{}
if meta {
if len(data) > 0 && data[0] == '\n' {
return fmt.Errorf("missing metadata json")
}
i := bytes.Index(data, []byte("\n\n"))
if i < 0 {
return fmt.Errorf("missing metadata json")
}
if err := json.Unmarshal(data[:i], &m); err != nil {
return fmt.Errorf("parsing metadata: %v", err)
}
data = data[i+2:]
}
var old Tiddler
err := client.Get(ctx, Key(title), &old)
if err != nil && err != datastore.ErrNoSuchEntity {
return err
}
if !meta && old.Meta != "" {
if err := json.Unmarshal([]byte(old.Meta), &m); err != nil {
return fmt.Errorf("parsing metadata: %v", err)
}
}
m["bag"] = "bag"
m["title"] = title
rev := 1
if old.Rev != 0 {
rev = old.Rev + 1
}
m["revision"] = rev
now := Now()
if old.Rev == 0 {
m["created"] = now
m["creator"] = user
}
if m["created"] != nil {
m["modified"] = now
m["modifier"] = user
}
if m["type"] == nil && old.Meta == "" {
ctype := http.DetectContentType(data)
if strings.HasPrefix(ctype, "text/") {
ctype = "text/vnd.tiddlywiki"
}
m["type"] = ctype
}
var t Tiddler
t.Rev = rev
js, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("encoding metadata: %v", err)
}
t.Meta = string(js)
tags, _ := m["tags"].([]interface{})
for _, s := range tags {
if s, ok := s.(string); ok {
t.Tags = append(t.Tags, s)
}
}
if !utf8.Valid(data) {
typ, _ := m["type"].(string)
if !needBase64[typ] {
return fmt.Errorf("invalid UTF-8")
}
t.Text = base64.StdEncoding.EncodeToString(data)
} else {
t.Text = string(data)
}
t1 := t
if _, err := client.Put(ctx, Key(title), &t1); err != nil {
return err
}
t2 := t
if _, err := client.Put(ctx, HistoryKey(title, rev), &t2); err != nil {
return err
}
return nil
}
var needBase64 = map[string]bool{
// cd TiddlyWiki5; gg registerFileType | grep base64
"application/epub+zip": true,
"application/font-woff": true,
"application/pdf": true,
"application/zip": true,
"audio/mp3": true,
"audio/mp4": true,
"audio/ogg": true,
"image/gif": true,
"image/jpeg": true,
"image/png": true,
"image/x-icon": true,
"video/mp4": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
}
func RunEditor(filename string) error {
ed := os.Getenv("VISUAL")
if ed == "" {
ed = os.Getenv("EDITOR")
}
if ed == "" {
ed = "ed"
}
// If the editor contains spaces or other magic shell chars,
// invoke it as a shell command. This lets people have
// environment variables like "EDITOR=emacs -nw".
// The magic list of characters and the idea of running
// sh -c this way is taken from git/run-command.c.
var cmd *exec.Cmd
if strings.ContainsAny(ed, "|&;<>()$`\\\"' \t\n*?[#~=%") {
cmd = exec.Command("sh", "-c", ed+` "$@"`, "$EDITOR", filename)
} else {
cmd = exec.Command(ed, filename)
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("invoking editor: %v", err)
}
return nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment