Created
November 13, 2019 17:07
-
-
Save rsc/9cf2c4f62b36ed8173bb2322226e8772 to your computer and use it in GitHub Desktop.
Acme Tiddler client - does not compile!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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