commit c41cc36a3a86140a208b95c679e1c2ac9a29efc4 Author: wehub-resource-sync Date: Mon Jul 13 12:30:20 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a160f9e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: zakirullin \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d424c93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.vscode/** +.idea/** +**/.DS_Store +storage +fslog +tests/node_modules/ +tests/test-results/ +tests/playwright-report/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5e3327 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1.7 + +# --- build stage --------------------------------------------------------- +FROM golang:1.24-alpine AS build +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-s -w -X main.Version=${VERSION}" \ + -o /out/server ./cmd/server + +# --- runtime stage ------------------------------------------------------- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -g 1000 app && adduser -D -u 1000 -G app app + +WORKDIR /app +COPY --from=build /out/server /app/server +COPY web /app/web + +RUN mkdir -p /app/storage /app/tokens && chown -R app:app /app +VOLUME ["/app/storage", "/app/tokens"] + + +USER app + +ENTRYPOINT ["/app/server"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9d9bcc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Artem Zakirullin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6800843 --- /dev/null +++ b/Makefile @@ -0,0 +1,148 @@ +.PHONY: server docker_build docker_run compose_up compose_down + +server: + go run ./cmd/server + +docker_build: # build container image (override with `make docker_build DOCKER=podman`) + $(DOCKER) build -t files-md --build-arg VERSION=$$(git rev-parse --short HEAD) . + +docker_run: # run container, host 80 -> container 8080 + $(DOCKER) run --rm -it -p 80:8080 \ + -v files-md-storage:/app/storage \ + -v files-md-tokens:/app/tokens \ + -e APP_URL=http://localhost \ + -e STORAGE_DIR=/app/storage \ + -e TOKENS_DIR=/app/tokens \ + -e CERT_DIR= \ + files-md + +compose_up: # build + start via compose.yaml, logs attached + $(DOCKER) compose up --build + +compose_down: # stop and remove the compose container (named volumes survive) + $(DOCKER) compose down + +DOCKER ?= docker + +test: + go test ./... + +check: + go fmt ./... && go vet ./... && go test ./... + +lint: + golangci-lint run + +format: + gofumpt -w . + +e2e: # make e2e test="create and move" + killall server || true + go run ./cmd/server > /tmp/server_err 2>&1 & \ + cd tests && npm run test $(if $(test),-g "$(test)") + +e2eh: # headed e2e tests + killall server || true + go run ./cmd/server & \ + cd tests && npm run test:headed $(if $(test),-g "$(test)") + +e2es: # run single test, e2es test="name" + cd tests && npm run test -- $(if $(test),-g "$(test)") + +e2esh: # run single test headed, e2esh test="name" + cd tests && npm run test:headed -- $(if $(test),-g "$(test)") + +perf: + cd tests && PERF=1 npm run test -- perf.spec.js + +sync: + killall server || true + go run ./cmd/server & \ + cd tests && npm run test --g "sync" + +synch: + killall server || true + go run ./cmd/server & \ + cd tests && npm run test:headed --g "sync" + +report: + cd tests && npx playwright show-report + +define ENV_FILE +BOT_API_TOKEN= +API_HOST=$(apihost) +APP_HOST=app.files.md +STORAGE_DIR=/app/storage +CERT_DIR=/opt/files.md +TOKENS_DIR=/opt/files.md/tokens +LOG_FILE=/var/log/files.md/server.log +endef + +define SERVICE_FILE +[Unit] +Description=Files.md Server +After=network.target + +[Service] +User=www-data +ExecStart=/app/server +WorkingDirectory=/app +Environment=TOKENS_SALT=$(salt) +Restart=always +RestartSec=5 +StandardOutput=append:/app/log +StandardError=append:/app/err +AmbientCapabilities=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target +endef + +# make init_server host=root@1.2.3.4 salt=my-secret-salt apihost=api.example.com +export ENV_FILE SERVICE_FILE +init_server: # create directories and configuration files on the server + ssh $(host) 'sudo mkdir -p /app/storage /var/log/files.md /opt/files.md /opt/files.md/tokens && \ + sudo chown -R www-data:www-data /app /var/log/files.md /opt/files.md' + echo "$$ENV_FILE" | ssh $(host) 'sudo tee /app/.env > /dev/null && sudo chown www-data:www-data /app/.env' + echo "$$SERVICE_FILE" | ssh $(host) 'sudo tee /etc/systemd/system/filesmd.service > /dev/null' + ssh $(host) 'sudo systemctl daemon-reload && sudo systemctl enable filesmd.service' + @echo 'Directories created and permissions set successfully.' + +deploy_systemd: # deploy as systemd service + @GREEN='\e[32m'; \ + YELLOW='\e[33m'; \ + RESET='\e[0m'; \ + COMMIT_HASH=$$(git rev-parse --short HEAD); \ + printf "$${YELLOW}Building (version=$${COMMIT_HASH})...$${RESET}\n" && \ + make check && \ + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$${COMMIT_HASH}" -o /tmp/server ./cmd/server && \ + printf "$${GREEN}Build Completed$${RESET}\n" && \ + scp /tmp/server $(host):/tmp/server.new && printf "$${GREEN}The binary is copied on the server$${RESET}\n" && \ + ssh $(host) "sudo mv /tmp/server.new /app/server && sudo systemctl daemon-reload && sudo systemctl restart filesmd.service" && \ + rm /tmp/server && \ + printf "$${YELLOW}Version: $${COMMIT_HASH}$${RESET}\n" && \ + TMPWEB=$$(mktemp -d) && \ + cp -r web "$${TMPWEB}/web" && \ + find "$${TMPWEB}/web" -name "*.html" -exec grep -l "?v=" {} \; | xargs sed -i '' 's/?v=/?v='"$${COMMIT_HASH}"'/g' && \ + tar --no-xattrs --disable-copyfile --no-fflags -czf "$${TMPWEB}/web.tar.gz" -C "$${TMPWEB}" web && \ + scp "$${TMPWEB}/web.tar.gz" $(host):/app/ && \ + ssh $(host) "cd /app && tar -xzf web.tar.gz && rm web.tar.gz" && \ + rm -rf "$${TMPWEB}" && \ + printf "$${GREEN}Successfully deployed!$${RESET}\n" + +deploy_binary: # deploy as regular binary, kinda deprecated, but ok for simple setup + @GREEN='\e[32m'; \ + YELLOW='\e[33m'; \ + RESET='\e[0m'; \ + COMMIT_HASH=$$(git rev-parse --short HEAD); \ + printf "$${YELLOW}Building (version=$${COMMIT_HASH})...$${RESET}\n" && \ + make check && \ + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$${COMMIT_HASH}" -o /tmp/server ./cmd/server && \ + printf "$${GREEN}Build Completed$${RESET}\n" && \ + ssh $(host) "sudo killall server || true" && \ + scp /tmp/server $(host):/tmp/server.new && printf "$${GREEN}The binary is copied on the server$${RESET}\n" && \ + ssh $(host) "sudo mv /tmp/server.new /app/server && sudo setcap 'cap_net_bind_service=+ep' /app/server" && \ + ssh $(host) "sudo su -c \"cd /app && nohup ./server >> /app/log 2>>/app/err &\" -s /bin/sh www-data" && \ + rm /tmp/server && \ + printf "$${GREEN}Successfully deployed!$${RESET}\n" + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf0961e --- /dev/null +++ b/README.md @@ -0,0 +1,411 @@ +Files.md icon + +# Files.md +Private, quiet space for thinking. Simple app for `.md` files. + +

+Files.md screenshot + English · 简体中文 +

+ +You can store your whole life: +- 📌 Notes +- 📝 Documents, Projects +- 💚 Journal, Habits +- ✅ Checklists, Tasks + +All in plain `.md` files, local-first. LLM-friendly. **Private - your data stays at your device**. + +Try it out: [app.files.md](https://app.files.md) (Beta). Main site: [files.md](https://files.md). + +You can use this app for Second Brain, Zettelkasten, Notes, Journaling, Tasks, Checklists, and more. + +> Own your data as plain local files. +> Own the software that opens those files. +> Grow your personal workspace with files. +> Grow the software around it with an LLM. +> **Local files and owned software last ages**. + +**I have been building this project for 5 years**. Consider **[supporting on GitHub 💚](https://github.com/sponsors/zakirullin)**. + +[Dump your thoughts](#dump-your-thoughts) · [How to think deeply](#how-to-think-deeply) · [Second Brain?](#second-brain) · [Chatbot](#chatbot) · [Journal, tasks, checklists](#journal) · [Files structure](#files-structure) + +## Another note taking app? +Maybe. But this time: +- Only necessary features, **restrictions foster creativity** +- Free and open source +- No need to install anything, all you need is a browser +- Works offline +- **Local-first, files don't leave your device** +- Extremely simple code. **One person or an LLM can fit the whole project in head** +- In other words, this project has [low cognitive load](https://github.com/zakirullin/cognitive-load) +- The codebase is ready for your LLM to extend to your needs +- Portable, no build systems, no Electron, just open `web/index.html` +- Optional out of the box synchronization +- The server is just one binary (or use iCloud/Dropbox/Google Drive for sync) +- Telegram chatbot for on-the-go access to your files +- **Chat-like flow for effortless thought capture** + +## How to use +- Open [app.files.md](https://app.files.md) +- Click "Install files.md" on the right side of the address bar: +
+ Install +
+ +- Open a local folder to persist changes +- Occasionally hit force-refresh (`Cmd+Shift+R`) to get new updates. + +P.S. For now, Chromium-based browsers (like Chrome, [Brave](https://github.com/zakirullin/files.md/issues/61)) are best at [File System API support](https://caniuse.com/native-filesystem-api). + +## Dump your thoughts +Open the chat (`Cmd+Enter`) and send a message: +
+ +
+ +Choose where to save (can do later): +
+ +
+ +**With this flow you can quickly save notes, tasks, journal records and checklists**. + +
+ Files.md screenshot +
+ +Everything can be synchronized with the chatbot. + +## Chatbot +Open the chat, write something and press `Enter`: + +
+ Saving things in bot +
+ +That's it, your note is saved to an `.md` file. + +Full bot's functionality: +
+ +
+ +Don't worry - it's much simpler than that by default. + +[Telegram Bot](https://t.me/FilesMDBot). *Other messengers will follow...* + +## How to think deeply +I used [app.files.md](https://app.files.md) to improve my thinking in brain and software development area. + +How I did that: +- I read articles and books +- I took notes in the chat, so that my reading flow wasn't interrupted +- One idea or insight per note +- Each note should be understandable without context +- I moved notes to either `brain` or `dev` folders (can be done after reading) + +The main thing at this point is not to distract yourself from the reading process. That's why I use the chat. + +Occasionally, I did the following thought work: +- **I spent time travelling through the notes and thinking them through** +- I made connections between the relevant notes (type `[`) +- Notes should be connected, just like neurons in our brain +- **I practiced my new knowledge, I applied it out in the world** +- At one point, some ideas in `brain` and `dev` folders appeared very related +- This connection between two different domains produced an insight +- I wrote an article based on that insight: [Cognitive Load in Software Development](https://github.com/zakirullin/cognitive-load) + +All this activity helped me to: +- **Think deeply** (which is very important in the AI-age) +- **Think systematically and see the bigger picture** +- **Write insightful texts** + +To achieve all that, **you'll have to use your brain**, not advanced templates or AI workflows. + +- Start with no structure at all, 0 folders +- One idea per note +- Every note should be understood without context +- Apply new knowledge immediately, don't save it for future self +- Link related notes +- **Revisit your notes and think through** + +My friends and I have been using this simple setup for five years, and it works well. + +## Second Brain? +I'll quote [I Deleted My Second Brain](https://web.archive.org/web/20260518143828/https://www.joanwestenberg.com/i-deleted-my-second-brain-692aa40d59d5f06dd5131e43/): + +> Obsidian is a brilliant piece of software. I love it, dearly. But like anything, without restraint, it can also be a trap. Markdown files in nested folders. Plugins that track your productivity. Graph views that suggest omniscience. There’s an illusion of mastery in watching your notes web into constellations. But constellations are projections. They tell stories. They do not guarantee understanding. +> +> When I first started using PKM tools, I believed I was solving a problem of forgetting. Later, I believed I was solving a problem of integration. +> +> **Eventually, I realized I had created a new problem: deferral. The more my system grew, the more I deferred the work of thought to some future self who would sort, tag, distill, and extract the gold.** +> +> **That self never arrived.** + +The Second Brain is thrilling. +Advanced guru templates, plugins and AI workflows... +One wants to scrape the wisdom of the whole internet. +There's some beauty in this neat system. Every new note brings dopamine. + +*Second Brain is getting better*. +**But the first brain is not improving**. +And that’s an issue. +In the AI age, your first brain is as valuable as ever. + +Use **your brain** to think through the notes. +The tool is not important, your thinking is. + +Before adding a new note, try to answer these questions: +- How this new knowledge can sharpen my judgment or expand my taxonomy? +- How can I see the world differently, given this new knowledge? +- How can I act differently? + +## Notes can prevent experience +- Reading and taking notes can easily fool us into believing that we understand a text +- We think we understand, but in reality **we just know** +- **At some point our "knowing" is so good, that we start feeling that we actually do it (or at least tried)** + +The worst thing is that **we don’t let new experiences emerge because we already have knowledge**. It's a knowledge barrier. Life gives us opportunities to live through new experiences, but we refuse, because "we already know". + +## Self-help through reading and taking notes? +**Harm caused at the emotional level must be healed at the emotional level.** + +Not through intellectual work and taking notes. +Reading without action is entertainment. A form of procrastination. +**No amount of self-help books can heal emotional wounds.** +What can help is psychotherapy, rescripting and chair work. Meditation. +**Healing happens by feeling.** + +## When to take notes +If your goal is to: +- Develop a deeper, more structured understanding of something +- Do research +- Write an article or a book + +Then taking notes is perfectly fine. + +## Journal +Are you feeling good about something? Send a message. + +
+ +
+ +Then click "To Journal". +Or just add ` jj` or ` жж` at the end of your message. + +Your record is gonna be saved nicely in the `journal/YYYY.MM Month.md` file. + +## Tasks ✅ +You're in the flow. +A colleague asks you to send a report. +Holding that in your head drains energy. +Drop a message and stay in flow. + +
+ +
+ +Add only small, actionable items. Not things like "Plan a vacation". +Your task should be the first, small step of what should be done anyway. +**Not what you wish to be done, but don't have motivation just yet.** +Your task list should not create a feeling of guilt. + +For tasks that should be done later press "To Later". + +## Checklists 🛒 +A friend recommends a book to you. +You're out of butter and buns. +Holding things like this in your head is taxing. + +Drop all that to chat, then move to a matching checklist. + +## How to sync +| Setup | Where your files live | Sync across devices | Server needed | Best for | +|-------------------------------------------------------|---------------------------------------|-------------------|--------------------|------------------------------------------------------| +| **Local-first**, `app.files.md` doesn't send any data | A folder on your device | No | None | Maximum privacy, your data doesn't leave your device | +| **Cloud-folder sync** (iCloud/Dropbox/Google Drive) | Your existing cloud folder | Yes | None (cloud provider) | Sync across devices without running a server | +| **[Self-hosted sync server](docs/your-own-server.md)** | Your own (or local) server | Yes | One Go binary | Sync between devices inside your network | +| **Hosted sync server** | Our managed server | Yes | `api.files.md` | Try it instantly, no setup | + +## Files structure +You don't have to think about the structure, it is predefined. +Although, you're free to use whatever structure you want. + +- Chat: `Chat.md` +- Notes: `brain/Note.md`, `/*.md` +- Projects: `Project.md`, `*.md` +- Checklists: `Read.md`, `Watch.md`, `Shop.md` +- Journal: `journal/2024.08 August.md` +- Tasks: `Later.md` +- Habits: `habits/Ate consciously.md`, `habits/*.md` +- Images: `media/*` (png, jpg, webp, gif) +- Archive: `archive/*.md` +- Config: `config.json` + +Place [AGENTS.md](https://github.com/zakirullin/cognitive-load/blob/main/web/AGENTS.md) in your knowledge base - so that your files become a live personal space AI can work with. + +## Hotkeys + +| Hotkey | Action | +|----------------------------------------|------------------------------| +| `[` | Insert a link to a file | +| `Cmd+K` / `Ctrl+K` | Open file search modal | +| `Cmd+N` / `Ctrl+N` | New file | +| `Cmd+M` / `Ctrl+M` | Move file | +| `Cmd+D` / `Ctrl+D` | Delete file | +| `Cmd+Enter` / `Ctrl+Enter` | Open chat | +| `Cmd+Shift+Enter` / `Ctrl+Shift+Enter` | Toggle chat dialog | +| `Cmd+[` / `Ctrl+[` | Go to previous file | +| `Cmd+]` / `Ctrl+]` | Go to next file | +| `Cmd+~` / `Ctrl+~` | Toggle sidebar | +| `Cmd+B` / `Ctrl+B` | Toggle **bold** | +| `Cmd+I` / `Ctrl+I` | Toggle *italic* | +| `Cmd+Y` / `Ctrl+Y` | Insert checkbox | +| `Cmd+T` / `Ctrl+T` | Insert table | +| `Cmd/Ctrl` + `Click` | Copy inline text / open link | +| `Ctrl+Cmd+Space` | Insert emoji (macOS) | + +## Useful scripts for your files +All scripts are in `cmd` and can be run **inside your files directory**. Install [Go](https://go.dev/doc/install) first. + +### Add Whoop metrics to journal +``` +go run /abs/path/to/files.md/cmd/whoop/whoop.go +``` + +### Convert wikilinks to markdown links +Convert `[[wikilinks]]` to standard `[Name](/path.md)` (`--dry-run` available): +``` +go run /abs/path/to/files.md/cmd/tomdlinks/tomdlinks.go . +``` + +### Insert backlinks +Adds links back to referencing files (`--dry-run` available): +``` +go run /abs/path/to/files.md/cmd/backlink/backlink.go +``` + +### Shift journal timestamps +Shift timestamps in journal files by N hours (useful after timezone change): +``` +go run /abs/path/to/files.md/cmd/shifttime/shifttime.go +``` + +## Documentation +[Deploy on your own server](docs/your-own-server.md) +[Chatbot](docs/bot.md) +[Sync flow](docs/sync-flow.md) +[End-to-end tests](docs/e2e-tests.md) + +## Repository structure +- `web` - web app (PWA) +- `web/index.html` entrypoint for the web app +- `web/lib` - web app libs +- `cmd/server` - entrypoint for the server +- `cmd/*/` - useful scripts for `.md` files +- `server` - server code +- `server/bot.go` - chatbot +- `server/sync/` - sync API server code +- `vendor` - backend libs +- `tests` - E2E tests, test both the web app and the server + +## How to contribute +- Junior developers should be able to understand the code +- Ideally, every PR should remove or simplify code, not add it +- **The less code we have, the more flexible we are** +- All dependencies are our code and responsibility. So, avoid dependencies if possible +- Code should be self-sufficient, so `vendor` and `web/lib` folders are included in the repository +- **Do we really need this feature? Will it help us to do the real job, or does it just give dopamine?** + +Refer to [this guide](https://github.com/zakirullin/cognitive-load) for more comprehensive rules. + +## Backend guidelines +- We write **tests** +- We don't use get* prefix for methods +- No panics, errors are part of business logic +- If we are ignoring an error - we leave a WHY comment +- We wrap errors all the time, we should add method's context +- No iterators for client code +- We prefer real implementations or at least fakes over mocks and stubs +- Imports should only be renamed to avoid a name collision with other imports +- **With portability in mind, everything is stored in plain `.md` files** + +## Frontend guidelines +- Use `PATCHED` keyword if you modify libs in-place +- **It would be fantastic if, one day, we replaced `CodeMirror` with our own tiny implementation** +- No build systems, **in 10 years we will open `/web/index.html` and it should just work** +- Don't forget that awaits between lock check and lock acquire can cause race condition +- Avoid flaky e2e tests. First we get negative emotions, then we stop running all the tests +- Most bugs are caused due to race conditions, when an async flow is interrupted mid through + +## Glossary +- `filename` - a filename with extension, like "note.md" (USE THIS AS ID) +- `header` - an extension-stripped and capitalized filename, like "Note" +- `body` - file's content +- `dir` - a dir that is meant to store notes under some category, like "happiness" +- `userID` - chatID. For the most part we're only using chatID as userID (PM with the bot) +- `ctime` for file - data blocks or metadata change time: file's ownership, location, file type and permission settings changed time. Parent folder renaming won't affect, moving the file does affect, renaming the file does affect. We need this to track file's location changes, like to understand when it was moved to archive, to track task's angry level etc +- `mtime` for file - mtime (modification time) for a file refers to the time when the contents of the file were last modified. Unlike ctime, it is not affected by changes to the file's metadata, such as ownership, permissions, or renaming. We rely on that for synchronization. +- `ctime` for dir - adding or removing files or subdirectories (similar to `mtime` plus inode changes like renaming files) + +Any file can be uniquely identified by filename and dir. We only support one level of nesting. + +## Performance +The project is blazing fast :) If you're afraid of using files or mutexes unnecessarily for performance reasons, take a look at this: +``` +Mutex lock/unlock = 25 ns +Read 4K randomly from SSD = 150,000 ns +1 ms = 1,000,000 ns +``` + +## ADRs (Architecture Decision Records) +- `20.06.2026` Backlinks are now inserted automatically whenever we link a note. For our workspace to be cross-platform (so you can view it even in GitHub), we insert backlinks on the fly, and not infer them dynamically. That way from any viewer backlinks would work. +- `01.06.2026` Audio and video are now supported. I believe enriching journal with these kind of media can be useful for our emotional healing. +- `24.05.2026` Unfold everything (images, math, mermaid) in viewport immediately. It prevents flickering and doesn't add performance penalty. P.S. We should also schedule full document unfold on viewportChange, otherwise latex/mermaid block off the viewport won't be folded. +- `22.05.2026` Added Mermaid support. It was decided to lazy-load the script, because mermaid.min.js size is 3MB. It's quite a load to load synchronously for such a small app. +- `20.05.2026` Added LaTeX support, even though I wasn't happy about +20 font files. LaTeX is text-based and LLM-friendly. Text + Math will cover pretty much everything. +- `06.05.2026` Moved from Today.md to Chat.md. CustDev showed that users have trouble grasping "today" concept. And besides, "open chat" phrase has meaning in both bot and webapp. +- `02.05.2026` Now hide-token runs synchronously on every change, previously it had 100ms debounce which caused jitter on by word removals in links and formatted texts. +- `02.05.2026` Merged Inbox.md and Today.md to Today.md. Inbox name is too abstract, productivity-related and GTD-ish. I want calmness and simplicity. Today is like "the page I live in chat". +- `23.04.2026` Moved from API_HOST, APP_HOST to API_URL, APP_URL. For different environments it's better to provide more information like desired schema in configuration. +- `22.04.2026` Inbox entries in the bot are now identified by a stable content hash (`fs.Hash` of the block with the `- [ ] `/`- [x] ` marker stripped) instead of a positional index, so a button keeps pointing at the right line even if other entries are added/removed/completed in between. +- `22.04.2026` It was mentally taxing to see two buttons/messages "to inbox" and "to chat", it was not as mentally easy just to drop a task for chat. Because it went to inbox, and 1 more click needed. That one click was the reason adding new tasks became frustrating. I let go of two different flow, and now everything goes to inbox, and every item is inbox is a markdown checklist item. As a bonus, PWA app is now very handy as it shows tasks for chat by default. Also, maybe "inbox" is a mentally overloaded term, and "chat" sounds better. Will see. +- `11.04.2026` Even though I want to store links as plain markdown links, visually I want to work with them as if they were minimal [links]. For that I decided to hide (...) part when cursor is on the line. The (...) part is only hidden for markdown-files link. +- `11.04.2026` Brought back standard Markdown Links. I want the knowledge base to be cross-platform. It should work in GitHub. +- `05.04.2026` Tried to move web/* stuff in the root folder for simplicity. Bad decision - there should be an explicit dir which we can use as public DOCROOT on our server. +- `21.09.2025` Switched to [link] for links. The `[link](full%20path)` syntax is too overwhelming and clunky, plus we don't want to deal with path changes. +- `21.09.2025` Removed WASM. I had a bug when a message was removed from Inbox.txt, and was not added to a file (I pressed "move to file" button). I wasn't able to reproduce the issue, but what I found is a lot of complexity. JS -> Go (writeFile) -> Go awaiting a promise from JS -> JS Golang runtime somewhere in between -> JS (writeFile) -> Go (returning from promise) -> Sending results back to JS. And it has to be done in a separate goroutine, because both WASM and JS are running in the same thread. Also, Golang's WASM is still experimental. We have too many components and a lot of uncertainty involved. I didn't want to implement same functionality in JS back then, at the solution served for some time. Now it's time to reimplement the functionality in JS and give up all this complexity. Also, inbox.wasm is ~8MB and I wanted the application to be really small. +- `11.07.2025` Decided to use OPFS as an initial driver for file system. Better browsers support, less hustle for users. The app starts with OPFS driver by default, if needed, user can replace the driver with Local FileSystem API by opening a local dir. DirHandle would be saved to IndexedDB in such scenario and reused every time. +- `08.07.2025` Root folder is now '/', not ''. All files in webapp are identified by path, not by 'dir' + 'filename', restricting to 1 level of nesting. +- `08.07.2025` Dropbox is changing some metadata for newly created files, thus ctime is changed. I was thinking about moving to mtime for sync, but that wouldn't allow us detect renames (though, we detect them through a separate mechanism anyway), so mtime can be more reliable. Also sync won't be triggered by permission/ownership change etc. Migrated to mtime. Mtime is used for content-based sync, ctime is used for append-only sync log (renames/del). Also we can restore mtime from .git/archive, unlike ctime. +- `30.06.2025` Decided to migrate every flow to Chat.md, even todo lists. Added - we can't work with multiline tasks with this flow, we may want support both files and indices. We have two ways of doing so - encode params in a uniform way, and use same command handlers with IFs. Or we can use different command handlers to handle chat/file movements. I decided to go for different command handlers. Added, if we go for different commands - move to buttons config would be complicated. Added, maybe we can move files back to Chat.md on "file move", and reuse the existing flow? Added, so far seems good. Our chat.md log acts as an append-only log. As a bonus, if we don't finish some flow (like schedule/move), the content would be saved in log and we can continue scheduling/moving from the app. +- `29.06.2025` All incoming messages go to Chat.md now by default. Before that they got moved to `/today` (and become tasks), which was good for a simple todo list, but not as convenient for other use cases. I realized that during meetings, all I needed was a simple input field where I can dump whatever stuff from my head with no further immediate action. With a possibility to review and organize it later. It can be tasks, it can be journal records, or it can be files. Also, it's better to have a really simple easy to understand default flow - we dump all the messages into one file, and that's it. +- `27.06.2025` Default mode for chat is "One big file" now, i.e. the only thing it does is dumps all the messages into one file. Again, let's start with the simplest flow, not to overwhelm users. Added later. If we choose full mode, we'll have to create dirs upfront so that "to habits", "to read/shop" etc. would work. If users don't need it, he removes the dirs, and we don't recreate them (as we would do in "on-the-fly mode"). So, we can't use on-the-fly strategy everywhere. +- `26.06.2025` Before we created all necessary dirs upfront, now we create dirs on the fly. That way we won't clutter user's knowledge base right from the start. +- `24.06.2025` Switched to microseconds for tracking file changes during sync. Gap between consecutive files creation is more than enough - ranging from 5000μs to 1000μs. We didn't go for nanosec because js is having troubles with int64 precision. Added later. Linux is using cached kernel time, which is updated at `CONFIG_HZ` interval (`grep CONFIG_HZ /boot/config-$(uname -r)`), in my case the value is 1000 (1ms). Most real-world operations operations are spaced much further apart than 1ms due to: user interaction, network latency, disk i/o. We might only have issue if we update files inside an effective/native loop. +- `16.06.2025` I believe it's time to make our knowledge base cross-platform, by forbidding characters like ":?<>*" in filenames. These characters aren't allowed in some environments (like Windows, PWA). +- `14.06.2025` I wanted bot-like functionality in browser. I didn't want to re-write well-tested code in TypeScript, so I used wasm~~. And it worked perfectly good. +- `12.06.2025` We use Telegram bot as distract-free write-only entrance to our knowledge base. The only issue is, it is not as wildly popular in EU/USA. I've come to the idea that we can transform app.files.md to a chat once we decrease the window size! Would be default behaviour on mobiles. +- `04.06.2025` Introduced append-only log for syncing. Stateless sync is tricky to implement - we would have to send all files in every request. Since we're only renaming on server - we'll only track renames. +- `04.06.2025` For content-only sync (no renames/deletes) we don't store any state on server, we compare hashes & last ctimes. +- `11.11.2024` Removed Wikilinks support. Only plain Markdown links, our knowledge base must be interoperable. +- `26.10.2024` Updates are now processed sequentially on per-user basis. Because there were some race conditions on concurrent file writings. Also we faced out-of-order forwarded messages processing, and it was impossible to collapse them to one message. +- `06.10.2024` **Removed fyne.io**. At first, I wanted a lightweight alternative to Electron, and fyne.io seemed to be an ideal candidate. After a few days working with it 80% of bot functionality was implemented, and I was pretty happy with it. The thing is, to implement the rest of the functionality, we would have to apply A TREMENDOUS amount of effort. I am talking tiny details such as scrolling, emojis rendering, text selecting behaviour, links support, etc. And in future we would have to implement image uploading and markdown/html renderer, which would be also painful in such non-webview based toolkit. As much as I hate using the web stack for the desktop applications, it doesn't seem like we have a choice. Let's try wails.io. +- `09.09.2024` We use vendoring for dependencies. We want all our few dependencies to be in the repo, so we don't care about blocked/removed dependencies. Our repository is the self-sufficient source of truth. +- `01.09.2025` We use granular locks (in db, journal, userconfig) instead of one global per user lock so to avoid bottlenecks. Workers might use 3rd party API like ChatGPT, and we don't want to hold user's lock all that time. **PATCHED**, we added sequential per-user updates processing, `bot` can't cause RCs on its own, but `bot` & `worker` can, so we should continue using granular locks. +- `20.08.2024` We read every userconfig value from the config file on every access. We don't need load/save whole config before/after `bot.Answer()` method. We have to reread it every time we need to change it, so we don't write back any stale data. Let's imagine we load config only once before `bot.Answer()`, next, we may have significant networking delays in `bot.Answer()` (let's say 2 seconds when making external requests), there are good changes that during those 2 seconds `worker.MoveDueTasks()` will modify `userconfig.Schedule`, causing data race (after bot's answer we write back stale data). And we don't want our schedule lost. +- `08.08.2024` Sanitize Early, we gave up sanitizing in Path method. That's an unexpected behaviour - it breaks paths. We should sanitize everything as soon as we received. Most commands work with md5 hashes, for such cases no sanitize is needed. +- `13.07.2024` `gofumpt` for stricter formatting. `gofumpt` is happy with a subset of the formats that gofmt is happy with. The less we have to choose between different formating options, the better. +- `13.07.2024` FS's structure should have userFS name, to reflect the fact it user user-namespaced. +- `09.07.2024` Note term is way too vague. Let's try to use "file" term, without any high level abstraction (like note). +- `08.07.2024` Gave up on AST parsing/rendering. We had lots of corner cases via AST and the code was way complex. Markdown isn't that hard to parse, we can do it via good old straigforward code. We have 3x times less code now, and it is far less mentally taxing to understand. We did the same for MD->HTML conversion. Telegram doesn't support whole range of HTML tags, so it was easier to write our own md-to-html converter. +- `08.07.2024` Adherence to Tolerant Reader principles. If enconunter gibberish during parsing - we skip it, but if we encounter flags of valid data (let's say `###`) but data itself is invalid - we panic. TODO preserve gibberish during read-write cycle. +- `07.07.2024` Usage of https://github.com/rivo/uniseg. In Go, strings are read-only slices of bytes. They can be turned into Unicode code points using the for loop or by casting: []rune(str). However, multiple code points may be combined into one user-perceived character or what the Unicode specification calls "grapheme cluster". For example, white circle "⚪" has two runes, but one grapheme cluster. +- `13.06.2024` Markdown to HTML conversion. User can have invalid Markdown in his notes, and TG API would fail to send invalid Markdown directly. So, first we escape HTML, then we convert user's Markdown to HTML and finally send it via Telegram API as HTML. +- `13.06.2023` File hashing. Everywhere where we have user input - we should use fs.hash, otherwise we get long filenames, and tg returns `INVALID_DATA` error (callbackData max 64 bytes). +- `13.06.2023` Introduced `db.go`. We had to abstract away Redis anyway (otherwise it's hard to write tests). +- `13.03.2023` Package db.go doesn't store userID (we often use it separately...) Do we? Maybe we gonna use it without userID (like global bot stats?). Added: moved userID to class. Maybe in later we'll need this class outside of user's scope, but let's stay in the future. :) +- `13.06.2023` We can't ucfist filename in fs.Put - what if that was user-created file (outside the bot), i.e. it comes with lowercase. + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b132058 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`zakirullin/files.md` +- 原始仓库:https://github.com/zakirullin/files.md +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..e562e70 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,387 @@ +Files.md icon + +# Files.md +一处私密、安静的思考空间。一款只为你 `.md` 文件而生的简单应用。 + +

+Files.md screenshot + English · 简体中文 +

+ +你的整段人生,都可以放在这里: +- 📌 笔记 +- 📝 文档、项目 +- 💚 日记、习惯 +- ✅ 清单、任务 + +全部以纯 `.md` 文件保存,本地优先,对 LLM 友好。**隐私无虞——任何数据都不会上传到服务器**。 + +> 让你的数据,以纯粹的本地文件形式属于你。 +> 让打开这些文件的软件,也真正属于你。 +> 用文件和你自己的大脑,去培育知识。 +> 借助 LLM,去打磨文件周围的软件。 +> **属于自己的纯文件与软件,才能穿越时代。** + +线上体验:[app.files.md](https://app.files.md)(Beta)。主站:[files.md](https://files.md)。 + +你可以把它用作第二大脑、卡片盒笔记(Zettelkasten)、笔记、日记、任务、清单等等。 + +**这个项目我已经做了 5 年。** 欢迎 **[在 GitHub 上支持作者 💚](https://github.com/sponsors/zakirullin)**。 + +[随手记下想法](#随手记下想法) · [如何深入思考](#如何深入思考) · [第二大脑?](#第二大脑) · [日记、任务、清单](#日记) + +## 又一个笔记应用? +也算是。但这一次: +- 只保留必要功能,**约束孕育创造力** +- 无需安装,一个浏览器就够了 +- 离线可用 +- **本地优先,文件不会离开你的设备** +- 自由开源 +- 代码极其简洁。**一个人或一个 LLM 能把整个项目装进脑子里** +- 换句话说,这个项目具有[低认知负荷](https://github.com/zakirullin/cognitive-load) +- 代码库为 LLM 留好了扩展空间,随你按需添砖加瓦 +- 便携,没有任何构建系统,直接打开 `web/index.html` 即可 +- 同步开箱即用,但完全可选 +- 服务器就是一个二进制文件(也可以直接用 iCloud / Dropbox / Google Drive 同步) +- Telegram 机器人,方便你在路上随时访问文件 + +## 如何使用 +- 打开 [app.files.md](https://app.files.md)(推荐 Chrome) +- 点击地址栏右侧的 "Install files.md": +
+ Install +
+ +- 打开一个本地文件夹,让改动持久化保存 +- 偶尔按 `Cmd+Shift+R` 强制刷新一下,拿到最新更新 + +## 如何同步 +| 方案 | 文件存放在哪儿 | 多设备同步 | 是否需要服务器 | 适合的人 | +|-------------------------------------------------------|--------------------------------|---------------|--------------------------|------------------------------------------------| +| **本地优先**,`app.files.md` 完全不发送任何数据 | 你设备上的某个文件夹 | 否 | 不需要 | 追求极致隐私,数据完全留在本地 | +| **云盘文件夹同步**(iCloud / Dropbox / Google Drive) | 你现有的云盘文件夹 | 是 | 不需要(由云服务商负责) | 不想自己跑服务器,也想多设备同步 | +| **[自托管同步服务器](docs/your-own-server.md)** | 你自己(或局域网内)的服务器 | 是 | 一个 Go 二进制 | 想在自家或公司网络里多设备同步 | +| **托管同步服务器** | 我们托管的服务器 | 是 | `api.files.md` | 想立刻上手,不折腾任何配置 | + +## 随手记下想法 +你可以打开聊天框,随手把脑子里冒出来的想法记下来。 + +Files.md screenshot + +打开聊天框,发一条消息: +
+ +
+ +挑一个去处(也可以稍后再决定): +
+ +
+ +**这条流程足够让你顺手收下笔记、任务、日记和清单。** + +## 在 Telegram 机器人里保存 +打开聊天,输入点东西,按 `Enter`: + +
+ Saving things in bot +
+ +就这样,你的笔记已经保存到一个 `.md` 文件里了。 + +机器人的完整功能: +
+ +
+ +别担心——默认情况下它比看上去简单得多。 + +[Telegram 机器人入口](https://t.me/FilesMDBot)。**其他即时通讯工具也会陆续支持。** + +## 如何深入思考 +**让想法相连,让它们彼此叠加,并真正想透。** + +1) 我用 [app.files.md](https://app.files.md) 来沉淀关于大脑和软件开发的认识 +2) 我把新笔记放进 `brain` 或 `dev` 文件夹,一条笔记只承载一个想法 +3) 在 Web 应用里,把彼此相关的笔记连接起来(按 `[` 即可) +4) 所有东西最终都连成一片,正如我们的大脑 +5) **我会花时间在这些笔记之间穿行、反复琢磨** +6) 久而久之,有些 `brain` 里的笔记和 `dev` 里的笔记开始看起来意外地相关 +7) 跨领域的这种连接,会激发出洞察 +8) 我据此写了一篇文章:[软件开发中的认知负荷](https://github.com/zakirullin/cognitive-load) + +这个过程帮助我: +- **深入思考**(在 AI 时代尤其重要) +- **系统性地思考,看见更完整的图景** +- **写出更有洞见的文字** + +要做到以上几点,**你必须用自己的脑子去想**,而不是依赖什么花哨模板或者 AI 工作流。 + +- 一开始什么结构都别建,0 个文件夹 +- 一条笔记只承载一个想法 +- 每条笔记要在脱离上下文时也能被读懂 +- 学到新东西就立刻用上,别留给"未来的自己" +- 把相关的笔记彼此链接起来 +- **常回去翻翻,再想想** + +我和我的朋友用这套朴素的方法已经五年了,效果一直很好。 + +## 第二大脑? +我想引用 [I Deleted My Second Brain](https://web.archive.org/web/20260518143828/https://www.joanwestenberg.com/i-deleted-my-second-brain-692aa40d59d5f06dd5131e43/) 里的一段话: + +> Obsidian 是一款出色的软件,我深爱它。但任何东西失去节制都会变成陷阱。嵌套文件夹里的 Markdown 文件、追踪你生产力的插件、暗示某种全知感的关系图谱——看着自己的笔记编织成星座般的网络,会让人产生一种掌控的幻觉。但星座只是投影。它们讲故事,却并不等于理解。 +> +> 刚开始使用知识管理工具时,我以为自己是在解决"遗忘"这件事。后来,我以为自己解决的是"整合"。 +> +> **最终,我意识到我创造了一个新问题:拖延。系统越是壮大,我就越是把真正的思考工作推给某个未来的自己——那个会整理、贴标签、提炼、淘金的自己。** +> +> **而那个自己从未到来。** + +第二大脑是一个迷人的概念。 +那些进阶模板、插件、AI 工作流…… +让人忍不住想把整个互联网的智慧都打包收下。 +这个整洁的系统看起来真美。每多一条笔记,多巴胺就涌出来一些。 +_第二大脑越来越好。_ + +可是,**第一大脑从来没有真正变得更聪明。** +这才是问题——在 AI 时代,你的第一大脑前所未有地宝贵。 + +用**你的脑子**去把笔记想透。 +工具并不重要,思考才是。 + +新增一条笔记之前,先试着回答这两个问题: +- 这则新知识能不能磨利我的判断、拓宽我的分类框架? +- 拥有这份知识后,我能用不同的视角看世界吗? + +## 笔记反而会阻碍体验 +- 一边读一边记,很容易让我们误以为自己理解了文本 +- 我们以为自己理解了,但其实**只是知道** +- **而当"知道"变得足够多,我们甚至会觉得自己已经"做过了"(或者起码尝试过了)** + +最糟糕的是,**我们因此不再让新的体验发生——因为我们"已经知道了"**。这就是一道知识的高墙。生活给了我们去亲身经历的机会,我们却拒绝,理由是"我已经知道了"。 + +## 通过阅读和做笔记自我疗愈?🧘‍ +**在情绪层面受到的伤害,必须在情绪层面治愈。** + +不是靠脑力劳动和做笔记。 +没有行动的阅读,是娱乐,是一种拖延的形式。 +**再多自助类书籍也治不好情感的伤口。** +真正有帮助的,是心理治疗、改写练习与椅子工作。是冥想。 +**疗愈来自感受。** + +## 什么时候应该做笔记 +如果你的目标是: +- 对某件事建立更深入、更有结构的理解 +- 做研究 +- 写一篇文章或一本书 + +那么记笔记完全合适。 + +## 日记 +对什么事情感觉良好?发一条消息。 + +
+ +
+ +然后点 "To Journal"。 +或者直接在消息末尾加上 ` jj` 或 ` жж`。 + +你的记录会被妥帖地存进 `journal/YYYY.MM Month.md`。 + +## 任务 ✅ +你正心流满满地工作着。 +同事突然让你发一份报告。 +把这件事一直挂在脑子里很耗能。 +丢一条消息,继续保持心流。 + +
+ +
+ +只添加小而可执行的事项。 +不要写"规划一次假期"那种。 +写下"反正要做的事情"的第一小步即可。 +**写你愿意去做的,不是你只是想象会去做但还没动力的。** +你的任务列表不该带来负罪感。 + +要稍后再做的事情,按 "To Later"。 + +## 清单 🛒 +朋友推荐了一本书给你。 +家里的黄油和面包没了。 +这些事一直挂在脑子里,会让人心累。 + +丢进聊天框,再转到对应的清单里。 + +## 文件结构 +你不必费心想结构,它是预定义的。 +当然,你也完全可以按自己的喜好来。 + +- 聊天:`Chat.md` +- 笔记:`brain/Note.md`、`/*.md` +- 清单:`Read.md`、`Watch.md`、`Shop.md`、`MyChecklist_.md` +- 日记:`journal/2024.08 August.md` +- 任务:`Later.md` +- 习惯:`habits/Ate consciously.md`、`habits/*.md` +- 图片:`media/*`(png、jpg、webp、gif) +- 归档:`archive/*.md` +- 配置:`config.json` + +这份结构也可以在 [files.md/AGENTS.md](https://files.md/AGENTS.md) 取到。 +把它作为 `AGENTS.md` 放进你的知识库——你的文件就成了 AI 可以参与的鲜活个人空间。 + +## 快捷键 + +| 快捷键 | 操作 | +|----------------------------------------|-------------------------------| +| `[` | 插入到某个文件的链接 | +| `Cmd+K` / `Ctrl+K` | 打开文件搜索框 | +| `Cmd+N` / `Ctrl+N` | 新建文件 | +| `Cmd+M` / `Ctrl+M` | 移动文件 | +| `Cmd+D` / `Ctrl+D` | 删除文件 | +| `Cmd+Enter` / `Ctrl+Enter` | 打开聊天 | +| `Cmd+Shift+Enter` / `Ctrl+Shift+Enter` | 切换聊天弹窗 | +| `Cmd+[` / `Ctrl+[` | 跳到上一个文件 | +| `Cmd+]` / `Ctrl+]` | 跳到下一个文件 | +| `Cmd+~` / `Ctrl+~` | 切换侧边栏 | +| `Cmd+B` / `Ctrl+B` | 切换**粗体** | +| `Cmd+I` / `Ctrl+I` | 切换*斜体* | +| `Cmd+Y` / `Ctrl+Y` | 插入复选框 | +| `Cmd/Ctrl` + `Click` | 复制纯文本 / 打开链接 | +| `Ctrl+Cmd+Space` | 插入 emoji(macOS) | + +## 一些好用的脚本 +所有脚本都在 `cmd` 目录下,**请在你的笔记文件夹里运行**。请先安装 [Go](https://go.dev/doc/install)。 + +### 把 Whoop 数据写进日记 +``` +go run /abs/path/to/files.md/cmd/whoop/whoop.go +``` + +### 把 wikilinks 转成 Markdown 链接 +把 `[[wikilinks]]` 转成标准的 `[Name](/path.md)`(支持 `--dry-run`): +``` +go run /abs/path/to/files.md/cmd/tomdlinks/tomdlinks.go . +``` + +### 插入反向链接 +在被引用的文件里加上回链(支持 `--dry-run`): +``` +go run /abs/path/to/files.md/cmd/backlink/backlink.go +``` + +### 调整日记时间戳 +把日记文件中的时间戳整体平移 N 小时(换时区后很实用): +``` +go run /abs/path/to/files.md/cmd/shifttime/shifttime.go +``` + +## 文档 +[在自己的服务器上部署](docs/your-own-server.md) +[Telegram 机器人](docs/bot.md) +[同步流程](docs/sync-flow.md) +[集成测试](docs/integration-tests.md) + +## 仓库结构 +- `web` —— Web 应用(PWA),入口是 `index.html` +- `web/lib` —— 前端依赖库 +- `cmd/server` —— 服务端入口 +- `cmd/*/` —— 处理 `.md` 文件的脚本 +- `server/bot.go` —— 机器人 +- `server/sync/` —— 同步 API 服务端 +- `vendor` —— 后端依赖 +- `tests` —— 端到端测试,会同时覆盖 Web 应用和服务端 + +## 如何贡献 +- 初级开发者也应能读懂代码 +- 理想情况下,每个 PR 都应该删减或简化代码,而不是新增 +- **代码越少,我们就越灵活** +- 所有依赖都是我们自己的代码与责任,能不引就不引 +- 代码应当自洽,所以 `vendor` 和 `web/lib` 都在仓库里 +- **我们真的需要这个功能吗?它能帮我们做"真正的事",还是只为了一时的多巴胺?** + +更完整的原则请参考 [这份指南](https://github.com/zakirullin/cognitive-load)。 + +## 后端规范 +- 我们写**测试** +- 我们不用 `get*` 前缀命名方法 +- 不要 panic,错误本身就是业务逻辑的一部分 +- 如果要忽略某个 error,必须留下解释 WHY 的注释 +- 我们一律包装 error,并补上方法层面的上下文 +- 客户端代码里不使用迭代器 +- 优先使用真实实现或至少 fake,而不是 mock / stub +- import 只在与其他 import 冲突时才重命名 +- **为了可移植性,所有数据都存在纯 `.md` 文件里** + +## 前端规范 +- 如果你就地修改了第三方库,请用 `PATCHED` 关键字标注 +- **要是有一天我们能用自己的小实现替换掉 `CodeMirror`,那就太棒了** +- 没有构建系统,**十年后我们打开 `/web/index.html`,它仍然要能直接跑** +- 别忘了:在"检查锁"和"获取锁"之间出现的 `await` 会引入竞态 +- 别写脆弱的 e2e 测试。先是带来负面情绪,然后我们就再也不跑测试了 +- 大多数 bug 都源于异步流程中途被打断时的竞态条件 + +## 术语表 +- `filename` —— 带扩展名的文件名,例如 "note.md"(**用它作为 ID**) +- `header` —— 去掉扩展名并首字母大写后的文件名,例如 "Note" +- `body` —— 文件内容 +- `dir` —— 用来按类别存放笔记的目录,例如 "happiness" +- `userID` —— 即 chatID。大多数场景下我们就直接用 chatID(私聊机器人) +- 文件的 `ctime` —— 数据块或元数据变更时间:所有权、位置、文件类型、权限变更都会触发。父目录改名不会触发,移动文件会触发,重命名会触发。我们需要它来追踪文件的位置变化,比如什么时候被移到归档、追踪任务的"愤怒度"等 +- 文件的 `mtime` —— 文件内容最近一次被修改的时间。和 `ctime` 不同,所有权、权限、改名都不会影响它。我们用它来做同步 +- 目录的 `ctime` —— 增删文件或子目录会触发(类似 `mtime`,再加上 inode 的变化,比如改名) + +每个文件都可以通过文件名与所在目录唯一定位。我们只支持一层嵌套。 + +## 性能 +项目跑起来飞快 :) 如果你怕用文件或互斥锁会带来性能开销,看一眼这张对比: +``` +Mutex 加 / 解锁 = 25 ns +从 SSD 随机读 4K = 150,000 ns +1 ms = 1,000,000 ns +``` + +## ADR(架构决策记录) +- `06.05.2026` 从 Today.md 切回 Chat.md。用户访谈发现"chat"这个概念用户更容易理解。而且 "open chat" 这句话在机器人和网页应用里语义都一致。 +- `02.05.2026` hide-token 现在在每次变更时同步运行;之前的 100ms 防抖会让链接和格式化文本在逐字删除时出现抖动。 +- `06.05.2026` 把 Inbox.md 和 Today.md 合并成了 Today.md。"Inbox"这个名字太抽象、太"生产力"和 GTD 味道。我想要的是从容和简洁。Today 更像"我活在聊天里的那一页"。 +- `23.04.2026` 把 API_HOST、APP_HOST 改成了 API_URL、APP_URL。不同环境下,配置里能携带 schema 等更多信息总归更好。 +- `22.04.2026` 机器人里的 Inbox 条目改用稳定的内容哈希识别(对去掉 `- [ ] ` / `- [x] ` 标记后的块取 `fs.Hash`),而不是位置序号。这样即便中间有条目被新增/删除/完成,按钮也仍指向正确的那一行。 +- `06.05.2026` 同时存在 "to inbox" 和 "to chat" 两个按钮,心智上挺累。也很难轻松地把任务往 chat 里随手一丢——因为它会先落到 inbox,还要多点一次。这一次多余的点击,正是让"添加新任务"变得令人厌烦的原因。我放弃了两条不同的流程,现在一切都先进 inbox,每一项都是 Markdown 复选框。额外的好处:PWA 默认展示 chat 里的任务,特别顺手。也许 "inbox" 这个词心智负担太重,"chat" 听起来更轻松。再观察一下。 +- `11.04.2026` 虽然我希望以纯 Markdown 链接形式存储链接,但视觉上我希望它们能像极简的 `[link]` 一样工作。所以当光标停在该行时,我会隐藏 `(...)` 那部分。仅对指向 md 文件的链接生效。 +- `11.04.2026` 又把标准 Markdown 链接拉回来了。我希望这个知识库跨平台可用,它必须在 GitHub 上也能正常显示。 +- `05.04.2026` 试过把 `web/*` 移到根目录以求简洁,结果发现是个坏主意——必须有一个显式的目录,作为服务器上的公开 DOCROOT。 +- `19.04.2026` 改成了 `[link]` 形式的链接。`[link](full%20path)` 太重也太突兀,我们也不想处理路径变更。 +- `21.09.2025` 移除了 WASM。我当时遇到一个 bug:从 Inbox.txt 删除一条消息后,按"move to file"没有把它写到目标文件里。我没复现出来,但发现了一大堆复杂度:JS -> Go(writeFile)-> Go 等待来自 JS 的 promise -> 中间的 JS Golang 运行时 -> JS(writeFile)-> Go(promise 返回)-> 把结果发回 JS。而且这一切还得跑在独立 goroutine 里,因为 WASM 和 JS 共用一个线程。再加上 Go 的 WASM 仍是实验性的。组件太多、不确定性太大。当时我不愿意用 JS 重写已经能用的逻辑,于是用了 WASM 撑了一段时间。现在该用 JS 把它重新实现,把这一整摞复杂度都甩掉。另外 `inbox.wasm` 大约 8MB,我希望整个应用尽可能小。 +- `11.07.2025` 决定先用 OPFS 作为文件系统的初始驱动。浏览器兼容性更好,对用户更省心。应用默认以 OPFS 启动;用户需要时可以打开一个本地目录,切到 Local FileSystem API。DirHandle 会被保存到 IndexedDB,下次复用。 +- `08.07.2025` 根目录现在是 `/` 而不是空字符串。Web 应用里所有文件按路径唯一标识,不再用 `dir + filename`,并限制为一层嵌套。 +- `11.04.2026` Dropbox 会改动新创建文件的某些元数据,因此 `ctime` 会被改写。我考虑过把同步切到 `mtime`,但这样就检测不出重命名(虽然我们另有机制检测重命名),而 `mtime` 整体上更可靠:权限/所有权变更也不会触发同步。最终切换到了 `mtime`。`mtime` 用于内容级同步,`ctime` 用于追加式同步日志(重命名 / 删除)。另外 `mtime` 可以从 `.git/archive` 里恢复,`ctime` 不行。 +- `30.06.2025` 决定把所有流程,包括待办列表,都迁移到 Chat.md。补充:这种流程不太方便处理多行任务,可能需要同时支持文件和索引。两种实现思路:参数统一编码,复用同一个命令处理器加 if 分支;或者用不同的命令处理器分别处理 chat / 文件。我选了后者。补充:如果走分流,"move to" 按钮的配置会更复杂。补充:也许在"move to file"时把文件移回 Chat.md,复用现有流程?补充:目前看来还不错。我们的 chat.md 充当一个仅追加日志。额外的好处是:即便某些流程(比如安排时间 / 移动)没走完,内容也保留在日志里,下次可以继续。 +- `06.05.2026` 所有新进消息现在默认进 Chat.md。在此之前它们会被移到 `/chat` 并变成任务,这对简单的 to-do 列表很合适,但对其他用例不够灵活。我意识到开会时,我真正需要的就是一个简单的输入框,把脑子里乱七八糟的东西先丢进去,不必立即决定怎么处理,回头再整理就好。它们可能是任务、可能是日记、也可能就是文件。同时保持一条简单到无需理解的默认流程,对用户更友好——所有消息都丢进同一个文件,仅此而已。 +- `27.06.2025` Chat 现在默认是"一个大文件"模式,也就是把所有消息都堆进同一个文件,不做更多事。还是那句话:从最简单的流程开始,不要一上来就把用户淹没。补充:如果选了完整模式,我们就得提前建好目录,"to habits"、"to read/shop" 之类才能工作。用户用不上的话,把目录删了我们就不再自动重建(不像"按需创建"模式那样)。所以"按需创建"并不适合所有场景。 +- `26.06.2025` 之前是提前把所有目录都建好,现在改成按需创建。这样不会一上来就把用户的知识库塞得乱七八糟。 +- `24.06.2025` 同步时改用微秒级别追踪文件变化。连续创建文件之间的间隔通常在 1000μs–5000μs,足够区分。没用纳秒是因为 JS 处理 int64 精度有问题。补充:Linux 用的是内核缓存时间,按 `CONFIG_HZ` 间隔更新(`grep CONFIG_HZ /boot/config-$(uname -r)`),我这里是 1000(1ms)。实际操作之间的间隔通常远大于 1ms:用户交互、网络延迟、磁盘 I/O 都会拉长间隔。只有在原生紧凑循环里更新文件才可能踩坑。 +- `16.06.2025` 我觉得是时候让知识库真正跨平台了——禁掉文件名里的 `:?<>*` 等字符。这些字符在某些环境(Windows、PWA)里本来就不允许。 +- `15.09.2025` 我想在浏览器里有类机器人的能力。又不想把已经被良好测试过的代码用 TypeScript 重写一遍,所以用了 wasm。它确实跑得挺好。 +- `12.06.2025` 我们把 Telegram 机器人当作通往知识库的"无干扰、只写"入口。唯一的问题是它在欧美没那么流行。后来想到:把窗口缩小后,app.files.md 也可以直接呈现成一个聊天界面!这将是移动端的默认形态。 +- `04.06.2025` 引入了用于同步的追加式日志。无状态同步实现起来很费劲——每次请求都要把所有文件发过去。既然服务器端只处理重命名,那我们只追踪重命名就好。 +- `04.06.2025` 仅做内容同步(不含重命名 / 删除)时,服务端不存任何状态,只比较哈希和最近的 ctime。 +- `11.11.2024` 移除了 Wikilinks 支持。只保留纯 Markdown 链接,我们的知识库必须保持互操作性。 +- `26.10.2024` 更新现在按用户串行处理。原因是并发写文件出现过竞态。同时还遇到过转发消息乱序的问题,导致无法合并成一条。 +- `06.10.2024` **移除了 fyne.io**。一开始我只是想找一个比 Electron 更轻量的方案,fyne.io 看起来是个理想候选。用了几天,机器人 80% 的功能已经搬过来了,体验也不错。但要把剩下 20% 落地,得花上巨大的工夫——滚动行为、emoji 渲染、文本选择、链接支持等等细枝末节。未来还要做图片上传、Markdown/HTML 渲染,在一个不基于 webview 的工具集里这些会非常痛苦。尽管我个人挺反感用 Web 技术做桌面应用,但似乎没得选。试试 wails.io。 +- `09.09.2024` 我们用 vendoring 管理依赖。我希望所有的少量依赖都在仓库里,这样就不必担心依赖被屏蔽或下线。我们的仓库就是自给自足的"唯一可信源"。 +- `09.06.2025` 我们在 db、journal、userconfig 里使用细粒度锁,而不是给每个用户上一把全局锁,以避免瓶颈。Workers 可能要调外部 API(如 ChatGPT),不希望长期持有用户锁。**已 PATCHED**——加入了按用户串行更新,`bot` 自身不会导致竞态,但 `bot` 和 `worker` 之间仍可能竞态,所以继续保留细粒度锁。 +- `20.08.2024` 我们在每次访问时都从配置文件读取 userconfig 的每个字段。不需要在 `bot.Answer()` 前后整体加载/保存配置。修改时必须重新读取,避免写回过时数据。设想一下:如果只在 `bot.Answer()` 之前加载一次,而 `bot.Answer()` 中又有可观的网络延迟(比如对外部接口发请求要 2 秒),那这 2 秒内 `worker.MoveDueTasks()` 完全可能改写 `userconfig.Schedule`,引发数据竞争(机器人答完后写回的就成了过期数据),调度信息会丢失。 +- `08.08.2024` 早期清洗(Sanitize Early)。我们放弃了在 `Path` 方法里做清洗——那种行为非常违反直觉,会把路径搞坏。东西一收进来就应该清洗。大多数命令都基于 md5 哈希工作,那种场景不需要清洗。 +- `13.07.2024` 用 `gofumpt` 做更严格的格式化。`gofumpt` 接受的格式是 gofmt 的子集。在格式选项上越少做选择,越好。 +- `13.07.2024` FS 的结构里应该带上 `userFS` 名字,明确它是按用户命名空间隔离的。 +- `09.07.2024` "Note" 这个词太空泛。我们改用 "file",不再套用"笔记"这种高层抽象。 +- `13.08.2024` 放弃了 AST 解析 / 渲染。走 AST 时遇到太多边界情况,代码也愈发复杂。Markdown 并不那么难解析,老老实实写直白的代码就好。最终代码量降到原来的 1/3,理解起来也轻松多了。MD->HTML 转换我们做了同样的事。Telegram 并不支持完整的 HTML 标签,自己写一个 md-to-html 转换器反而更容易。 +- `08.07.2024` 遵循 Tolerant Reader 原则。解析时遇到乱码就跳过;但如果看到看起来合法的标记(比如 `###`)后面跟着无效数据,就直接 panic。TODO:在读写循环中保留这些乱码。 +- `07.07.2024` 引入了 https://github.com/rivo/uniseg。在 Go 里,string 是只读的字节切片,可以通过 `for` 或 `[]rune(str)` 转成 Unicode 码点。但多个码点可能被组合成一个"用户感知"的字符,也就是 Unicode 规范里说的"字素簇"(grapheme cluster)。例如白色圆圈 "⚪" 由两个 rune 组成,却是一个字素簇。 +- `07.09.2024` Markdown 转 HTML。用户笔记里可能有非法 Markdown,直接发给 TG API 会失败。所以我们先转义 HTML,再把用户的 Markdown 转成 HTML,最后通过 Telegram API 以 HTML 形式发出。 +- `13.06.2023` 文件名哈希化。所有出现用户输入的地方都应该用 `fs.hash`,否则文件名会变得很长,而 Telegram 会返回 `INVALID_DATA`(callbackData 最大 64 字节)。 +- `13.06.2023` 引入了 `db.go`。本来就需要把 Redis 抽象出来(不然测试不好写)。 +- `13.03.2023` `db.go` 这个包里不存 `userID`(我们经常单独使用 `userID`)……真的吗?也许哪天我们会在与用户无关的场景下用它(比如全局 bot 统计)。补充:之后把 `userID` 挪进了类里。未来如果在用户作用域之外还要复用这个类再说,眼下先这样。 +- `13.06.2023` 我们不能在 `fs.Put` 里给文件名做首字母大写——万一这是用户在外部自己建的文件(小写)就被改坏了。 diff --git a/cmd/backlink/backlink.go b/cmd/backlink/backlink.go new file mode 100644 index 0000000..64ecd95 --- /dev/null +++ b/cmd/backlink/backlink.go @@ -0,0 +1,145 @@ +// Package scripts Backlink is a small script which is meant to insert backlinks into our notes +// You can run it manually on your knowledge base, or you can run it periodically +// Should be run with working directory set to your root knowledge base +// WARNING! Cases with "|" in urls aren't handled yet, so duplicate urls possible +// We don't support relative paths, missing folder means root +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/afero" + "golang.org/x/exp/slices" + "golang.org/x/text/unicode/norm" + + "github.com/zakirullin/files.md/server/fs" +) + +func main() { + dryRun := false + for _, arg := range os.Args[1:] { + if arg == "--dry-run" { + dryRun = true + } + } + + // [dir][note] => links referring to our note (backlinks) + backlinks := make(map[string]map[string][]string) + + userFS, err := fs.NewFS(".", afero.NewOsFs()) + if err != nil { + fmt.Printf("Can't create FS: %s", err) + return + } + + files, err := userFS.FilesAndDirs("") + if err != nil { + fmt.Printf("Can't get files and dirs: %s", err) + return + } + dirs := fs.OnlyNoteDirs(fs.OnlyDirs(files)) + + // Run through files, if our file has link to some note, we add our current note's path to referred note's backlinks + for _, dir := range dirs { + notes, err := userFS.FilesAndDirs(dir.Name) + if err != nil { + fmt.Printf("Can't get notes: %s", err) + } + + notes = fs.OnlyUserMDFiles(notes) + for _, note := range notes { + if filepath.Ext(note.Name) != fs.MDExt { + continue + } + + content, err := userFS.Read(dir.Name, note.Name) + if err != nil { + fmt.Printf("Can't get content: %s", err) + continue + } + + links := regexp.MustCompile(`\[\[(.+?)\]\]`) + matches := links.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + + link := match[1] + if strings.Contains(link, "img/") { + continue + } + // There are issues with "й" letter. Probably it has non-canonical encoding in mac FS + link = string(norm.NFC.Bytes([]byte(link))) + // We don't support labels + link = strings.Split(link, "|")[0] + + // dir/note + parts := strings.Split(link, "/") + + isInRootDir := len(parts) == 1 + if isInRootDir { + // TODO implement support for root files + continue + } + + refToDir := parts[0] + refToNote := parts[1] + + filename := note.Name + filename = string(norm.NFC.Bytes([]byte(filename))) + filename = strings.TrimSuffix(filename, fs.MDExt) + link = fmt.Sprintf("%s/%s", dir.Name, filename) + + if _, ok := backlinks[refToDir]; !ok { + backlinks[refToDir] = make(map[string][]string) + } + backlinks[refToDir][refToNote] = append(backlinks[refToDir][refToNote], link) + } + } + } + + for dir, notes := range backlinks { + for note, links := range notes { + for _, link := range links { + content, err := userFS.Read(dir, note+fs.MDExt) + if err != nil { + fmt.Printf("Can't get target note '%s/%s.md':%s, backlinks: %v", dir, note, err, links) + continue + } + + var existingLinks []string + existingLinksRx := regexp.MustCompile(`\[\[(.*)\]\]`) + matches := existingLinksRx.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + existingLink := strings.Split(match[1], "|")[0] + existingLinks = append(existingLinks, existingLink) + } + + if slices.Contains(existingLinks, link) { + continue + } + + if dryRun { + fmt.Printf("would add '%s' to '%s/%s'\n", link, dir, note) + continue + } + + err = userFS.Write(dir, note+".md", fmt.Sprintf("%s\n[[%s]]", strings.TrimSpace(content), link)) + if err != nil { + fmt.Printf("Can't put to file: %s", err) + return + } + + fmt.Printf("ADD '%s' TO '%s/%s'\n", link, dir, note) + } + } + } +} diff --git a/cmd/backlink/backlink/main.go b/cmd/backlink/backlink/main.go new file mode 100644 index 0000000..948cace --- /dev/null +++ b/cmd/backlink/backlink/main.go @@ -0,0 +1,132 @@ +// Package scripts Backlink is a small script which is meant to insert backlinks into our notes +// You can run it manually on your knowledge base, or you can run it periodically +// Should be run with working directory set to your root knowledge base +// WARNING! Cases with "|" in urls aren't handled yet, so duplicate urls possible +// We don't support relative paths, missing folder means root +package main + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/afero" + "golang.org/x/exp/slices" + "golang.org/x/text/unicode/norm" + + "github.com/zakirullin/files.md/server/fs" +) + +func main() { + // [dir][note] => links referring to our note (backlinks) + backlinks := make(map[string]map[string][]string) + + userFS, err := fs.NewFS(".", afero.NewOsFs()) + if err != nil { + fmt.Printf("Can't create FS: %s", err) + return + } + + files, err := userFS.FilesAndDirs("") + if err != nil { + fmt.Printf("Can't get files and dirs: %s", err) + return + } + dirs := fs.OnlyNoteDirs(fs.OnlyDirs(files)) + + // Run through files, if our file has link to some note, we add our current note's path to referred note's backlinks + for _, dir := range dirs { + notes, err := userFS.FilesAndDirs(dir.Name) + if err != nil { + fmt.Printf("Can't get notes: %s", err) + } + + notes = fs.OnlyUserMDFiles(notes) + for _, note := range notes { + if filepath.Ext(note.Name) != fs.MDExt { + continue + } + + content, err := userFS.Read(dir.Name, note.Name) + if err != nil { + fmt.Printf("Can't get content: %s", err) + continue + } + + links := regexp.MustCompile(`\[\[(.+?)\]\]`) + matches := links.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + + link := match[1] + if strings.Contains(link, "img/") { + continue + } + // There are issues with "й" letter. Probably it has non-canonical encoding in mac FS + link = string(norm.NFC.Bytes([]byte(link))) + // We don't support labels + link = strings.Split(link, "|")[0] + + // dir/note + parts := strings.Split(link, "/") + + isInRootDir := len(parts) == 1 + if isInRootDir { + // TODO implement support for root files + continue + } + + refToDir := parts[0] + refToNote := parts[1] + + filename := note.Name + filename = string(norm.NFC.Bytes([]byte(filename))) + filename = strings.TrimSuffix(filename, fs.MDExt) + link = fmt.Sprintf("%s/%s", dir.Name, filename) + + if _, ok := backlinks[refToDir]; !ok { + backlinks[refToDir] = make(map[string][]string) + } + backlinks[refToDir][refToNote] = append(backlinks[refToDir][refToNote], link) + } + } + } + + for dir, notes := range backlinks { + for note, links := range notes { + for _, link := range links { + content, err := userFS.Read(dir, note+fs.MDExt) + if err != nil { + fmt.Printf("Can't get target note '%s/%s.md':%s, backlinks: %v", dir, note, err, links) + continue + } + + var existingLinks []string + existingLinksRx := regexp.MustCompile(`\[\[(.*)\]\]`) + matches := existingLinksRx.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + existingLink := strings.Split(match[1], "|")[0] + existingLinks = append(existingLinks, existingLink) + } + + if slices.Contains(existingLinks, link) { + continue + } + + err = userFS.Write(dir, note+".md", fmt.Sprintf("%s\n[[%s]]", strings.TrimSpace(content), link)) + if err != nil { + fmt.Printf("Can't put to file: %s", err) + return + } + + fmt.Printf("ADD '%s' TO '%s/%s'\n", link, dir, note) + } + } + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..6461867 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "runtime/debug" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/joho/godotenv" + "github.com/spf13/afero" + + "github.com/zakirullin/files.md/server" + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/db" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/pkg/tg" + "github.com/zakirullin/files.md/server/sync" + "github.com/zakirullin/files.md/server/userconfig" +) + +func main() { + if err := godotenv.Load(); err != nil { + fmt.Println("No .env file found, relying on process environment") + } + if err := config.LoadBotConfig(); err != nil { + panic(fmt.Sprintf("Error loading cfg: %s\n", err)) + } + // Save all renames and deletes to an append-only log. + fs.LogRename = sync.LogRename + fs.LogDelete = sync.LogDelete + + // Launch the HTTP server, it does two things: + // - Serves the PWA app + // - Provides the API for synchronization (if API_URL is not empty) + go sync.Serve( + config.ServerCfg.APIHost(), + config.ServerCfg.AppHost(), + config.ServerCfg.ServerCertDir, + config.ServerCfg.ServerLogFile, + ) + + // Telegram bot is optional - server can run as web-only. + api, err := tgbotapi.NewBotAPI(config.ServerCfg.BotAPIToken) + if err != nil { + fmt.Println("No Telegram bot token found, running web and api server only") + select {} // block forever + } + telegram := tg.NewTG(api) + + // If today or inbox was changed in web app, we need to send the updated items to the bot. + sync.OnChatUpdate = func(userID int64) { updateBotHome(telegram, userID) } + + // Due tasks scheduler + ticker := time.NewTicker(5 * time.Second) + quit := make(chan struct{}) + defer func(quit chan struct{}) { + close(quit) + }(quit) + go func(tg *tg.TG) { + fsBackend := afero.NewOsFs() + for { + select { + case <-ticker.C: + err := server.MoveDueTasks(config.ServerCfg.StorageDir, config.ServerCfg.ConfigFilename, fsBackend, telegram) + if err != nil { + fmt.Printf("Worker's error: %s\n", err) + } + + err = server.RemoveCompletedChecklistItems(config.ServerCfg.StorageDir, config.ServerCfg.ConfigFilename, fsBackend) + if err != nil { + fmt.Printf("Worker's error: %s\n", err) + } + case <-quit: + ticker.Stop() + return + } + } + }(telegram) + + infolog := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Main bot loop. + // Loop through updates from Telegram and process them sequentially in separate per-user goroutine. + userChannels := make(map[int64]chan tgbotapi.Update) + tgConfig := tgbotapi.NewUpdate(0) + tgConfig.Timeout = 60 // TODO release, check if it's enough + updates := api.GetUpdatesChan(tgConfig) + for update := range updates { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("Bot panic", "update", update, "err", r, "stacktrace", string(debug.Stack())) + } + }() + + updJSON, _ := json.Marshal(update) + infolog.Info("Bot update", "update", string(updJSON)) + + var userID int64 + upd := tg.NewTGUpd(update) + channelID, channelIDExists := upd.ChannelID() + if channelIDExists { + userID, err = telegram.ChannelCreatorID(channelID) + if err != nil { + slog.Error("Bot error: can't get channel creator ID", "upd", string(updJSON), "err", err) + } + } else { + userID = upd.UserID() + } + + userCh, channelIDExists := userChannels[userID] + if !channelIDExists { + userCh = make(chan tgbotapi.Update, 100) + userChannels[userID] = userCh + go supervisor(userID, userCh, telegram) + } + + userCh <- update + }() + } +} + +// Runs per-user worker that listens for updates. +// Restarts infinitely upon panics. +func supervisor(userID int64, updates <-chan tgbotapi.Update, telegram *tg.TG) { + for { + func() { + defer func() { + if err := recover(); err != nil { + slog.Error("Bot panic", "userID", userID, "err", err, "stacktrace", string(debug.Stack())) + } + }() + processUserUpdates(userID, updates, telegram) + }() + time.Sleep(time.Second) + slog.Info("Restarting worker", "userID", userID) + } +} + +func processUserUpdates(userID int64, updates <-chan tgbotapi.Update, telegram *tg.TG) { + for update := range updates { + upd := tg.NewTGUpd(update) + + bot, err := newBot(telegram, userID) + if err != nil { + slog.Error("Bot error: can't create bot", "err", err) + return + } + + if err := bot.Reply(upd); err != nil { + if errors.Is(err, tg.ErrFileTooBig) { + if _, sendErr := telegram.Send(userID, "This file is too big. Telegram allows bots to download files up to 20MB.", nil, ""); sendErr != nil { + slog.Error("Bot error: can't send file-too-big notice", "err", sendErr) + } + } + slog.Error("Bot error", "err", err) + } + } +} + +func newBot(telegram *tg.TG, userID int64) (*server.Bot, error) { + userFS, err := fs.NewUserFS(userID) + if err != nil { + return nil, fmt.Errorf("can't create fs: %w", err) + } + err = userFS.CreateSystemDirs() + if err != nil { + return nil, fmt.Errorf("can't create user dirs: %w", err) + } + + confFilename := config.ServerCfg.ConfigFilename + userconf := userconfig.NewConfig(userFS, userID, confFilename) + err = userconf.CreateDefaultIfNotExists() + if err != nil { + return nil, fmt.Errorf("can't create default user config: %w", err) + } + + bot := server.NewBot(userID, telegram, userFS, db.NewDB(userID), userconf) + + return bot, nil +} + +func updateBotHome(telegram *tg.TG, userID int64) { + bot, err := newBot(telegram, userID) + if err != nil { + slog.Error("Bot error: can't create bot", "err", err) + return + } + + err = bot.ShowHome(nil) + if err != nil { + slog.Error("Server error: can't update bot home", "userID", userID, "err", err) + } +} diff --git a/cmd/shifttime/shifttime.go b/cmd/shifttime/shifttime.go new file mode 100644 index 0000000..bcf8edb --- /dev/null +++ b/cmd/shifttime/shifttime.go @@ -0,0 +1,91 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +// adjustTime adjusts the time in a given line by the specified number of hours +func adjustTime(line string, hours int) string { + // Regex to match time in format HH:MM + re := regexp.MustCompile("^`\\d{2}:\\d{2}`") + match := re.FindString(line) + match = strings.Trim(match, "`") + if match != "" { + // Parse the time + t, err := time.Parse("15:04", match) + if err != nil { + fmt.Println("Error parsing time:", err) + return line + } + // Adjust the time by the specified number of hours + t = t.Add(time.Duration(hours) * time.Hour) + // Replace the time in the line with the new time + return strings.Replace(line, match, t.Format("15:04"), 1) + } + return line +} + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: go run main.go ") + return + } + + // Get the file name and hours from command line arguments + fileName := os.Args[1] + hours, err := strconv.Atoi(os.Args[2]) + if err != nil { + fmt.Println("Invalid number of hours:", err) + return + } + + // Open the file + file, err := os.Open(fileName) + if err != nil { + fmt.Println("Error opening file:", err) + return + } + defer file.Close() + + // Create a scanner to read the file line by line + scanner := bufio.NewScanner(file) + var lines []string + + // Process each line + for scanner.Scan() { + line := scanner.Text() + adjustedLine := adjustTime(line, hours) + lines = append(lines, adjustedLine) + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error reading file:", err) + return + } + + // Write the adjusted lines back to the file + outputFile, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating output file:", err) + return + } + defer outputFile.Close() + + writer := bufio.NewWriter(outputFile) + for _, line := range lines { + _, err := writer.WriteString(line + "\n") + if err != nil { + fmt.Println("Error writing to file:", err) + return + } + } + writer.Flush() + + fmt.Println("Time adjustment completed successfully.") +} diff --git a/cmd/shifttime/shifttime/main.go b/cmd/shifttime/shifttime/main.go new file mode 100644 index 0000000..bcf8edb --- /dev/null +++ b/cmd/shifttime/shifttime/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +// adjustTime adjusts the time in a given line by the specified number of hours +func adjustTime(line string, hours int) string { + // Regex to match time in format HH:MM + re := regexp.MustCompile("^`\\d{2}:\\d{2}`") + match := re.FindString(line) + match = strings.Trim(match, "`") + if match != "" { + // Parse the time + t, err := time.Parse("15:04", match) + if err != nil { + fmt.Println("Error parsing time:", err) + return line + } + // Adjust the time by the specified number of hours + t = t.Add(time.Duration(hours) * time.Hour) + // Replace the time in the line with the new time + return strings.Replace(line, match, t.Format("15:04"), 1) + } + return line +} + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: go run main.go ") + return + } + + // Get the file name and hours from command line arguments + fileName := os.Args[1] + hours, err := strconv.Atoi(os.Args[2]) + if err != nil { + fmt.Println("Invalid number of hours:", err) + return + } + + // Open the file + file, err := os.Open(fileName) + if err != nil { + fmt.Println("Error opening file:", err) + return + } + defer file.Close() + + // Create a scanner to read the file line by line + scanner := bufio.NewScanner(file) + var lines []string + + // Process each line + for scanner.Scan() { + line := scanner.Text() + adjustedLine := adjustTime(line, hours) + lines = append(lines, adjustedLine) + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error reading file:", err) + return + } + + // Write the adjusted lines back to the file + outputFile, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating output file:", err) + return + } + defer outputFile.Close() + + writer := bufio.NewWriter(outputFile) + for _, line := range lines { + _, err := writer.WriteString(line + "\n") + if err != nil { + fmt.Println("Error writing to file:", err) + return + } + } + writer.Flush() + + fmt.Println("Time adjustment completed successfully.") +} diff --git a/cmd/tomdlinks/tomdlinks.go b/cmd/tomdlinks/tomdlinks.go new file mode 100644 index 0000000..1487931 --- /dev/null +++ b/cmd/tomdlinks/tomdlinks.go @@ -0,0 +1,86 @@ +// Converts [[wikilinks]] to standard markdown [Link Name](/path/Link%20Name.md). +// Scans all .md files in a directory first to resolve link targets by filename. +// +// Usage: go run ./cmd/tomdlinks +// +// go run ./cmd/tomdlinks --dry-run +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func main() { + dryRun := false + dir := "." + + for _, arg := range os.Args[1:] { + if arg == "--dry-run" { + dryRun = true + } else { + dir = arg + } + } + + // Scan all .md files and build a map: display name -> relative path + candidates := map[string]string{} + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + rel, _ := filepath.Rel(dir, path) + name := strings.TrimSuffix(filepath.Base(rel), ".md") + candidates[name] = rel + return nil + }) + + wikiRE := regexp.MustCompile(`\[\[([^\[\]]+)\]\]`) + + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + content := string(data) + changed := false + + result := wikiRE.ReplaceAllStringFunc(content, func(match string) string { + name := match[2 : len(match)-2] + target, ok := candidates[name] + if !ok { + return match + } + // Escape parens too - an unescaped ) in a path closes the + // markdown link early (mirrors web's encodeLinkPath). + url := "/" + strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29").Replace(target) + changed = true + return fmt.Sprintf("[%s](%s)", name, url) + }) + + if !changed { + return nil + } + + rel, _ := filepath.Rel(dir, path) + if dryRun { + fmt.Printf("would update: %s\n", rel) + return nil + } + + err = os.WriteFile(path, []byte(result), info.Mode()) + if err != nil { + fmt.Fprintf(os.Stderr, "error writing %s: %s\n", rel, err) + return nil + } + fmt.Printf("updated: %s\n", rel) + return nil + }) +} diff --git a/cmd/whoop/whoop.go b/cmd/whoop/whoop.go new file mode 100644 index 0000000..95315e6 --- /dev/null +++ b/cmd/whoop/whoop.go @@ -0,0 +1,230 @@ +// Parses Whoop CSV exports and prints a 10-day journal summary. +// +// Usage: go run ./cmd/scripts/whoop +// Example: go run ./cmd/scripts/whoop ./whoop +package main + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type day struct { + Date time.Time + Sleep sleepData + Recovery recoveryData + Workouts []workout +} + +type sleepData struct { + Performance int + AsleepMin int + Present bool +} + +type recoveryData struct { + Score int + HRV int + RHR int + Strain float64 + Present bool +} + +type workout struct { + Activity string + DurationMin int +} + +func main() { + dir := "." + if len(os.Args) > 1 { + dir = os.Args[1] + } + + days := map[string]*day{} + dayOf := func(t time.Time) *day { + key := t.Format("2006-01-02") + if d, ok := days[key]; ok { + return d + } + d := &day{Date: t} + days[key] = d + return d + } + + // Parse sleeps - use wake onset date as the day, skip naps. + parseSleeps(filepath.Join(dir, "sleeps.csv"), dayOf) + + // Parse physiological cycles - recovery, HRV, RHR, strain. + parseCycles(filepath.Join(dir, "physiological_cycles.csv"), dayOf) + + // Parse workouts. + parseWorkouts(filepath.Join(dir, "workouts.csv"), dayOf) + + // Collect and sort days descending. + var sorted []*day + for _, d := range days { + sorted = append(sorted, d) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Date.After(sorted[j].Date) + }) + + // Print last 10 days. + n := 10 + if len(sorted) < n { + n = len(sorted) + } + for _, d := range sorted[:n] { + printDay(d) + fmt.Println() + } +} + +func parseSleeps(path string, dayOf func(time.Time) *day) { + rows := readCSV(path) + if len(rows) < 2 { + return + } + header := indexHeader(rows[0]) + + for _, row := range rows[1:] { + if field(row, header, "Nap") == "true" { + continue + } + + wake, err := parseTime(field(row, header, "Wake onset")) + if err != nil { + continue + } + + d := dayOf(wake) + d.Sleep.Present = true + d.Sleep.Performance = atoi(field(row, header, "Sleep performance %")) + d.Sleep.AsleepMin = atoi(field(row, header, "Asleep duration (min)")) + } +} + +func parseCycles(path string, dayOf func(time.Time) *day) { + rows := readCSV(path) + if len(rows) < 2 { + return + } + header := indexHeader(rows[0]) + + for _, row := range rows[1:] { + wake, err := parseTime(field(row, header, "Wake onset")) + if err != nil { + continue + } + + d := dayOf(wake) + d.Recovery.Present = true + d.Recovery.Score = atoi(field(row, header, "Recovery score %")) + d.Recovery.HRV = atoi(field(row, header, "Heart rate variability (ms)")) + d.Recovery.RHR = atoi(field(row, header, "Resting heart rate (bpm)")) + d.Recovery.Strain = atof(field(row, header, "Day Strain")) + } +} + +func parseWorkouts(path string, dayOf func(time.Time) *day) { + rows := readCSV(path) + if len(rows) < 2 { + return + } + header := indexHeader(rows[0]) + + for _, row := range rows[1:] { + start, err := parseTime(field(row, header, "Workout start time")) + if err != nil { + continue + } + + d := dayOf(start) + d.Workouts = append(d.Workouts, workout{ + Activity: field(row, header, "Activity name"), + DurationMin: atoi(field(row, header, "Duration (min)")), + }) + } +} + +func printDay(d *day) { + fmt.Printf("#### %d %s, %s\n", d.Date.Day(), d.Date.Format("January"), d.Date.Weekday()) + + if d.Sleep.Present { + h := d.Sleep.AsleepMin / 60 + m := d.Sleep.AsleepMin % 60 + fmt.Printf("- Sleep: %d%%, %dh %02dm\n", d.Sleep.Performance, h, m) + } + + if d.Recovery.Present { + fmt.Printf("- Recovery: %d%%, HRV %d, RHR %d\n", d.Recovery.Score, d.Recovery.HRV, d.Recovery.RHR) + if d.Recovery.Strain > 0 { + fmt.Printf("- Strain: %.1f\n", d.Recovery.Strain) + } + } + + for _, w := range d.Workouts { + fmt.Printf("- Workout: %s %dm\n", w.Activity, w.DurationMin) + } +} + +// CSV helpers + +func readCSV(path string) [][]string { + f, err := os.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s\n", err) + return nil + } + defer f.Close() + + r := csv.NewReader(f) + rows, err := r.ReadAll() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: can't parse %s: %s\n", path, err) + return nil + } + return rows +} + +func indexHeader(row []string) map[string]int { + m := make(map[string]int, len(row)) + for i, col := range row { + m[strings.TrimSpace(col)] = i + } + return m +} + +func field(row []string, header map[string]int, name string) string { + i, ok := header[name] + if !ok || i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) +} + +func parseTime(s string) (time.Time, error) { + return time.Parse("2006-01-02 15:04:05", s) +} + +func atoi(s string) int { + // Handle float strings like "421.0" + if strings.Contains(s, ".") { + f, _ := strconv.ParseFloat(s, 64) + return int(f) + } + v, _ := strconv.Atoi(s) + return v +} + +func atof(s string) float64 { + v, _ := strconv.ParseFloat(s, 64) + return v +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6616e9b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + files-md: + build: . + image: files-md + ports: + - "80:8080" + # - "443:443" + volumes: + - storage:/app/storage + - tokens:/app/tokens + environment: + APP_URL: http://localhost + # API_URL: http://api.localhost # Uncomment if you want synchronization API + STORAGE_DIR: /app/storage + TOKENS_DIR: /app/tokens + CERT_DIR: "" # Provide a dir if you want HTTPS enabled, e.g. /opt/certs + restart: unless-stopped + +volumes: + storage: + tokens: diff --git a/docs/bot.md b/docs/bot.md new file mode 100644 index 0000000..2cce470 --- /dev/null +++ b/docs/bot.md @@ -0,0 +1,151 @@ +# Telegram bot + +How the server wires Telegram updates into per-user workers and how `Bot.Reply` decides what to do with each message. + +## High-level architecture + +```mermaid +flowchart TB + TG[Telegram API] + PWA[PWA app.files.md] + + subgraph Server [server - one binary] + Main[main update loop
api.GetUpdatesChan] + Router{Route by userID} + UCh[per-user channel] + Sup[supervisor goroutine
panic-recovering] + Proc[processUserUpdates
sequential loop] + Bot[Bot.Reply] + + Web[sync.Serve
HTTP API] + Worker[worker ticker 5s
MoveDueTasks
RemoveCompletedChecklistItems] + + UserFS[(UserFS
storage/userID/*.md)] + DB[(per-user DB
in-memory state)] + Cfg[(config.json)] + end + + TG -->|long poll updates| Main + Main --> Router + Router -->|first time: spawn| Sup + Router -->|every update| UCh + UCh --> Sup + Sup --> Proc + Proc --> Bot + + PWA <-->|POST /syncFilenames, /syncFile, /syncMediaFilenames, /syncMediaFile| Web + + Worker -->|due tasks| Bot + Bot -->|read/write .md| UserFS + Bot -->|temp state| DB + Bot -->|preferences| Cfg + Web -->|read/write .md| UserFS + Bot -->|send, edit, delete msgs| TG +``` + +The server runs one binary with three long-running components: + +- **Telegram update loop** (`cmd/server/server.go`) - long-polls Telegram, routes each update to a per-user goroutine. Per-user channels serialize one user's messages so concurrent edits to the same files can't race. +- **HTTP sync server** (`server/sync`) - serves the PWA's sync requests (`/syncFilenames`, `/syncFile`, `/syncMediaFilenames`, `/syncMediaFile`). When the web app changes `Chat.md` or the chat, it calls `OnChatUpdate` which triggers the bot to send the user a fresh home keyboard so the two stay in lockstep. +- **Worker ticker** - every 5 seconds moves scheduled tasks out of `later` into `chat`, and prunes completed checklist items. + +Everything reads and writes the same per-user filesystem tree (`UserFS`), which is the single source of truth - `.md` files on disk. The PWA fetches those same files through the sync API. + +## `Bot.Reply` - reply flow + +```mermaid +flowchart TD + Start([Update arrives at Bot.Reply]) + IQ{Inline query?} + Plug{Plugin.CanHandle?} + ViaBot{Sent via bot?
inline result} + Cmd{extractCmd
returns a command?} + IsCB{Callback query?} + HasImg{Has image?} + + Search[answerSearch
return file results] + ChanSave[addToFile
append to ChannelName.md] + PlugRun[plugin.Handle
send output
ShowHome] + FileReq[answerFileRequest
resolve file] + DelKB[delAllKeyboards] + Handler[dispatch to handlers map
e.g. showMoveTo, moveToDir,
complete, schedule, ...] + AnswerCB[AnswerCallbackQuery
completedMsg or empty] + SaveImg[saveFromImage] + SaveTxt[saveFromTextMsg] + + Start --> IQ + IQ -->|yes| Search --> End([return]) + IQ -->|no| Chan + Chan -->|yes| ChanSave --> End + Chan -->|no| Plug + Plug -->|yes| PlugRun --> End + Plug -->|no| ViaBot + ViaBot -->|yes| FileReq --> End + ViaBot -->|no| Cmd + Cmd -->|yes, not callback| DelKB --> Handler + Cmd -->|yes, callback| Handler + Handler --> IsCB + IsCB -->|yes| AnswerCB --> End + IsCB -->|no| End + Cmd -->|no| HasImg + HasImg -->|yes| SaveImg --> End + HasImg -->|no| SaveTxt --> End + + style Search fill:#dfe,stroke:#374,color:#000 + style ChanSave fill:#dfe,stroke:#374,color:#000 + style PlugRun fill:#dfe,stroke:#374,color:#000 + style FileReq fill:#dfe,stroke:#374,color:#000 + style Handler fill:#ffd,stroke:#c80,color:#000 + style SaveImg fill:#fde,stroke:#a36,color:#000 + style SaveTxt fill:#fde,stroke:#a36,color:#000 +``` + +The decision is strictly top-to-bottom - the first matching case wins. Green terminals are read-only or side-channel responses; yellow is the large callback/command dispatch table; red is the save path for fresh user content. + +### Main steps inside the save paths + +```mermaid +flowchart TD + subgraph txt [saveFromTextMsg] + T1([message text]) --> T2[extractMarkdown] + T2 --> T3{recent forward
within collapse window?} + T3 -->|yes| T4[createOrAdd to Chat.md] --> TEnd([return]) + T3 -->|no| T5{reply to a
previous bot msg?} + T5 -->|yes| T6[addToRepliedFile
append to that note] --> TEnd + T5 -->|no| T7[saveToInbox] + T7 --> T8{ChatOnlyMode?} + T8 -->|yes| T9[react 👌] --> TEnd + T8 -->|no| T10{JournalOnlyMode?} + T10 -->|yes| T11[moveToJournal] --> TEnd + T10 -->|no| T12[showMoveTo
buttons: to Home,
to file, to dir, ...] --> TEnd + end + + subgraph img [saveFromImage] + I1([photo/document]) --> I2[DownloadFile] + I2 --> I3[fs.Write media/tg_*.ext] + I3 --> I4[build markdown image link
plus caption] + I4 --> I5[same branching as saveFromTextMsg:
collapse -> reply -> Inbox -> showMoveTo] + end +``` + +Both save paths converge on `saveToInbox` (append to `Inbox.md` with a timestamp) and then `showMoveTo`, which presents the user with action buttons. Picking a button fires a callback that re-enters `Bot.Reply`, hits the command branch, and runs the matching handler from the big map at `bot.go:327–415` (move to file, move to dir, schedule, complete, share, rename, etc.). + +### What the handlers table looks like + +A small taste of the command namespace (~90 entries in total, defined as `CmdX` constants around `bot.go:128–207`): + +| Category | Examples | +| --- | --- | +| Views | `ShowHome`, `ShowLater`, `ShowFiles`, `ShowDirs`, `ShowChecklists`, `ShowSettings` | +| Move | `MoveToExistingDir`, `MoveToNewFile`, `MoveToJournal`, `MoveToLater`, `MoveToChecklist`, `MoveToRead`/`Watch`/`Shop` | +| Complete | `Complete`, `CompleteFromInbox`, `CompleteListItem`, `CompleteHabit` | +| Schedule | `Schedule`, `ScheduleForTmrw`, `ShowScheduleForDay`, `Pomodoro` | +| Rename | `ShowRename`, `ShowRenameFile`, `Rename` | +| Settings | `TasksOnlyMode`, `NotesOnlyMode`, `JournalOnlyMode`, `FullMode`, `ChatMode`, `Timezone` | +| Other | `OpenInApp`, `Download`, `Share`, `Help`, `Stats` | + +Shortcut suffixes like ` jj` / ` жж` (append to journal) or `++` (append to most recently used file) are expanded into normal commands in `extractCmd` before dispatch. + +## Concurrency guarantees in one line + +One user's updates are processed strictly sequentially inside their own goroutine, but different users run in parallel - so the bot never races its own file writes for a single user, and the web app's sync API can safely modify the same files as the bot because the per-user worker holds the only write path for bot-initiated changes. diff --git a/docs/e2e-tests.md b/docs/e2e-tests.md new file mode 100644 index 0000000..e49ec65 --- /dev/null +++ b/docs/e2e-tests.md @@ -0,0 +1,39 @@ +# End-to-end tests + +## One-time setup + +Create a `.env` file in the project root: + +``` +APP_URL=http://localhost:3000 +API_URL=http://localhost:8080 +TOKENS_DIR="/` at the project root. Playwright runs tests in parallel (8 workers by default; see `tests/playwright.config.js`), and each worker's server-side user storage is isolated by its worker ID: + +- `storage/0/`, `storage/1/`, …, `storage/7/` - per-worker user filesystems. `tests/sync.spec.js` `beforeEach` wipes and recreates the current worker's directory on every test, so state never leaks between tests. +- `storage/-1/` - shared tokens directory. Contains one file per worker, named by `sha256(workerIndex + salt)`, whose contents are the worker's user ID. Used by the server's token middleware to authenticate incoming sync requests from the test browser. + +Feel free to delete `storage/` whenever - it's rebuilt on the next `beforeEach`. diff --git a/docs/sync-flow.md b/docs/sync-flow.md new file mode 100644 index 0000000..728e18d --- /dev/null +++ b/docs/sync-flow.md @@ -0,0 +1,163 @@ +# Sync flow + +How `openFile`, `syncCurrentEditor`, `syncFilesWithServer`, and `syncLocalFileWithServer` hand off work, and how the server knows what to send or receive. + +## Triggers and who calls whom + +```mermaid +flowchart TD + U[User: sidebar click, link click, popstate] -->|openFile| OF[openFile] + Timer1[setInterval 1000ms saver] -->|syncCurrentEditor| SCE[syncCurrentEditor] + Focus[focusin / focus event] -->|syncCurrentEditor| SCE + Timer2[setInterval syncFilesWithServer + syncMediaFiles] -->|syncFilesWithServer| STS[syncFilesWithServer] + + OF -->|save previous editor before swapping| SCE + SCE -->|switchAwayEditor=false: end of fn| SLF[syncLocalFileWithServer] + STS -->|per-file path| POST1[POST /syncFile per file] + SLF --> POST2[POST /syncFile] + + STS -->|batch modified/deleted| POST3[POST /syncFilenames] +``` + +The red node is the drift-seal line (files.js:1028). If `currentEditor` was rotated by anything during the yellow await above, this write lands on the wrong editor instance. + +## syncCurrentEditor - both flag branches + +```mermaid +flowchart TD + Enter([syncCurrentEditor switchAwayEditor]) --> G1{files undef, debug,
or path undefined?} + G1 -->|yes| Ret1([return]) + G1 -->|no| G2{isSaving OR
isMessingWithCurrentEditor?} + G2 -->|yes| Ret2([return]) + G2 -->|no| SetFlag[isMessingWithCurrentEditor = true;
path = currentEditor.path] + + SetFlag --> IsInbox{path == INBOX_PATH?} + IsInbox -->|yes| InboxBranch[chat sync logic] + InboxBranch --> Ret3([return]) + + IsInbox -->|no| RenameCheck{firstLine's filename
!= current filename?} + RenameCheck -->|yes| Rename[await remove path
await getFileHandle newPath
await writeIfContentIsDifferent
await renderSidebar] + Rename --> Ret4([return]) + RenameCheck -->|no| DiffCheck[await isContentEqual
path, getCurrentContent] + + DiffCheck --> SameGuard{isCurrentEditorSame?} + SameGuard -->|no| Ret5([return]) + SameGuard -->|yes| ModCheck{contentWasModifiedLocally
AND editor.isClean?} + + ModCheck -->|yes| SwitchGate{switchAwayEditor?} + SwitchGate -->|true: skip reload| Clear[isMessingWithCurrentEditor = false] + SwitchGate -->|false: reload from disk| Reload[await openFile path, false] + Reload --> Clear + + ModCheck -->|editor dirty| Save[write editor content to disk;
editor.markClean] + Save --> Clear + ModCheck -->|neither| Clear + + Clear --> ServerGate{switchAwayEditor?} + ServerGate -->|true: skip server sync| Done([return]) + ServerGate -->|false| PushServer[await syncLocalFileWithServer path] + PushServer --> Done + + style Rename fill:#faa,stroke:#900,color:#000 + style Reload fill:#fca,stroke:#c80,color:#000 + style SwitchGate fill:#cfc,stroke:#070,color:#000 + style ServerGate fill:#cfc,stroke:#070,color:#000 +``` + +The two green gates are the `switchAwayEditor` branches. The orange `Reload` is the one we neutralised (it used to recurse into `openFile` without an `el` arg and clobber the main editor). The red `Rename` block is the executioner that actually deletes and creates files on disk - still live, fires whenever first-line header disagrees with filename. + +## Sync with the server - batch vs per-file + +```mermaid +sequenceDiagram + participant Client + participant Server + + Note over Client: syncFilesWithServer fires + Client->>Client: collect modified and deleted files (skip editor and editor2 paths) + Client->>Server: POST /syncFilenames with modified, deleted, timestamps + Server-->>Client: files, timestamps, renames + Client->>Client: write non-current files to disk and update server.files snapshot + Client->>Client: advance per-dir timestamp pointers + + Note over Client: syncCurrentEditor finishes the switchAwayEditor=false branch + Client->>Client: syncLocalFileWithServer for the active editor + Client->>Server: POST /syncFile with path, lastModified, clientLastModified, clientLastSynced, content + alt notModified + Server-->>Client: notModified + Client->>Client: advance lastClientModified only + else updatedOnServer + Server-->>Client: updatedOnServer with new lastModified + Client->>Client: record the server lastModified, no disk write + else merged or ok + Server-->>Client: content and lastModified + Client->>Client: writeIfContentIsDifferent, then openFile if path matches editor.path + end +``` + +### How the server knows there's something to sync + +Two mechanisms, running in parallel: + +1. **Batch: `syncFilesWithServer` → `POST /syncFilenames`.** The client sends: + - `modified`: files whose disk `lastModified` is newer than the `lastClientSynced` pointer recorded in `server.files` for that path. + - `deleted`: files present in the client's `server.files` snapshot but no longer on disk. + - `timestamps`: a per-directory pointer telling the server "everything I've seen up to here." The server replies with files newer than each directory's pointer. **The two currently-open editor files are skipped on both send and receive** (`files.js:230` and `files.js:577`) - they're handled by the per-file path instead, to avoid racing with the user's active edits. + +2. **Per-file: `syncLocalFileWithServer` → `POST /syncFile`.** Called at the end of each `syncCurrentEditor` (when `switchAwayEditor=false`). Sends the single file's content plus its `lastModified` + `clientLastModified` + `clientLastSynced`. The server compares timestamps and responds with one of four statuses that the client maps to either "advance pointers only" or "write this content to disk." + +The client's `server.files` object holds the triple `(content, lastModified, lastClientModified)` per path - this is the client's view of what the server thinks the world looks like, and the basis for deciding which files to include in the next `modified`/`deleted` lists. Persisted to `localStorage` under `SERVER_STORAGE_KEY`. + +### Auth gate: `lastServerOk` + +The auth token lives in an HttpOnly cookie, so JS can't see it directly. Instead, every successful response from the server stamps `localStorage.lastServerOk` with `Date.now()` via `markServerOk()` (files.js). `hasLastServerOk()` returns true if that key exists - which it only can if the server has previously accepted us. Use this as the gate before kicking off sync work: no stamp ⇒ no token ⇒ skip the request entirely. The flag is set in: + +- `app.js` after the `/issuePermanentToken` exchange returns 200 +- `post()` after a 2xx response (covers all `/syncFilenames`, `/syncFile`, `/syncMediaFilenames`, `/syncMediaFile` upload calls now that they go through this helper) +- `syncMediaFiles` directly after the raw `POST /syncMediaFile` download (binary blob, can't share `post()`) + +If the server later 401s, the stamp stays - but the request will simply fail and no sync state advances, so we don't need to clear it. + +## File deletion propogation across clients + +A delete on one device has to travel through the server and reach every other device that still holds the file. The mechanism is an append-only `fslog` on disk: every server-side `userFS.Del` writes a ` del ` row, and every `/syncFilenames` response carries the deletes a given client hasn't seen yet. + +### Why we need this log at all + +Without it, the server only knows what currently exists on disk - it has no memory of what *used* to exist. Sync responses only list present files. So Client B, which still holds the deleted file locally, would see "this path is on my disk but not in the server response" and conclude it's a *new* local file → it would re-upload `foo.md` and the file resurrects. The fslog gives the server a memory of deletions, so it can tell B "yes, this used to exist, but it was deleted at time T - drop your stale copy." + +```mermaid +sequenceDiagram + autonumber + participant A as Client A + participant S as Server + participant L as fslog
(append-only file) + participant B as Client B + + Note over A,B: Steady state: both clients hold foo.md locally,
server has foo.md on disk + + rect rgb(245, 240, 230) + Note over A: User deletes foo.md in the PWA + A->>A: moveFile("/foo.md", "/archive/foo.md")
(local FS only) + A->>S: POST /syncFilenames
{ deleted: ["/foo.md"],
modified: [{path:"/archive/foo.md", ...}],
serverTime: } + S->>S: userFS.Del("foo.md")
removes from disk + S->>L: append " del /app/storage//foo.md" + S->>S: deletes = DeletesLog(uid, req.serverTime+1)
→ {"foo.md": } + S->>S: suppress echo: drop entries that
match request.Deleted + S->>S: write /archive/foo.md + S-->>A: response.deleted = {} (A's own delete
was filtered)
response.files = [...] + end + + Note over A,B: ...time passes, B opens app or hits sync interval... + + rect rgb(230, 240, 245) + B->>S: POST /syncFilenames
{ deleted: [], modified: [...],
serverTime: } + S->>L: scan fslog for this user + S->>S: deletes = DeletesLog(uid, req.serverTime+1)
→ {"foo.md": }
(no suppression: B didn't delete it) + S-->>B: response.deleted = {"foo.md": }
response.files = [archive/foo.md, ...] + B->>B: for each (path, deletedAt) in response.deleted:
local = getMemFile(path)
if local && local.lastModified ≤ deletedAt:
await remove(path)#59; removeServerFile(path) + B->>B: write archive/foo.md from response.files + end + + Note over A,B: Both clients converged:
foo.md gone, archive/foo.md present +``` diff --git a/docs/your-own-server.md b/docs/your-own-server.md new file mode 100644 index 0000000..06f5e52 --- /dev/null +++ b/docs/your-own-server.md @@ -0,0 +1,126 @@ +# Run your own server + +## Containerized deployment (Docker/Podman) + +Build and start: +```bash +$ docker compose up +``` + +### Enable HTTPS +In `compose.yaml`: set `CERT_DIR` to a persistent path, uncomment the `"443:443"` port. + + +## Deploy on your own server (manual) + +Install [Go](https://go.dev/doc/install) on your host machine. + +Initialize server with folders and systemd service. Tested on Debian-based systems: +```bash +$ make init_server host=user@example.com salt=$(head -c 32 /dev/urandom | base64) +``` + +Configure the `/app/.env` file: +``` +BOT_API_TOKEN= +STORAGE_DIR=/app/storage +CERT_DIR=/opt/files.md +TOKENS_DIR=/opt/files.md/tokens +LOG_FILE=server.log +API_URL=https://api.yourdomain.com +APP_URL=https://app.youdomain.com +``` + +Deploy a systemd service: +```bash +$ make deploy_systemd host= +``` + +That's all :) + +## Run your own Telegram Bot +1) Install [Go](https://go.dev/doc/install) +2) Register new telegram bot via [@BotFather](https://t.me/BotFather) +3) Add `BOT_API_TOKEN=` line to `.env` file +4) Redeploy/relaunch the server + +Bot's artifacts can be seen in `./storage/` folder. + +## Linking a new device +1) Open telegram bot +2) Open `/app` +3) Open the link in your browser +4) Device is now linked + +### Additional bot's settings +1) For search functionality, enable `Inline Mode` for your bot in [@BotFather](https://t.me/BotFather) +2) Press "Edit Commands", and send the following list: +``` +chat - 🏠 Home +files - 📄 Files +dirs - 🗂 Dirs +checklists - ☑️ Checklists +schedule - 📆 Schedule +postpone - 🦥 Postpone +rename - ✏️ Rename +move - ➡️ Move +app - 🔗 Open in app +settings - ⚙️ Settings +help - 📕 Help +``` + +## Hosting the bot on you local computer +You can host the bot locally, because it doesn't expose any ports to the outside world (if you don't use habits functionality). +It communicates with Telegram using pull API. + +Create a symlink to your local folder with `.md` files for convenience: +`ln -s storage/` + +## Transfer files to another server + +1) Backup your data (`/app/storage`) +2) Be sure that all client app fully synced with the server (bring the app in the focus) +3) Stop bot on old server, so no new files would be created. +4) Compress all the files on one server: `tar -czvf storage.tar.gz storage` +5) `scp` the file to your host machine: `scp SSH_HOST:/app/storage.tar.gz .` +6) `scp` the file to your target machine + +Synchronization is relying on `mtime`, so after compressing/decompressing the flag wouldn't be lost. + +1) `cd /opt/files.md` +2) `tar -czvf tokens.tar.gz tokens` +3) `scp` to same dir on target machine + +We don't need to transfer fslog (renames), if we're certain that all clients read the log. + +1) Extract all files on new server +2) Transfer `BOT_API_TOKEN` +3) Launch server +4) Execute `localStorage.setItem('ApiHost', 'YOUR_NEW_API_HOST');` in your PWA applications +5) Make sure that all files are available +6) Cleanup the oldserver + +## Maintenance notes +Add this to your crontab (`crontab -e`) for daily git backups: +`0 0 * * * cd /app/storage/ && git add . && git commit -m "$(date +\%d.\%m.\%Y)"` + +Execute `git init` in your folder before that, to init a git repository. + +If you have non-ASCI character in filenames, disable quoting: +`git config --global core.quotePath false` + +Systemd journal: +`sudo journalctl -u filesmd` + +Find forbidden character in filenames (can be executed in user's storage folder): +`find . -name '*[<>:"|\?*]*'` + +Remove forbidden filename characters: +```bash +find . -type f -name '*[<>:"|\?*]*' -print0 | while IFS= read -r -d '' f; do + dir=$(dirname "$f") + base=$(basename "$f") + newbase="${base//[<>:\"|\\?*]/}" + [ "$base" != "$newbase" ] && [ -n "$newbase" ] && mv -n -- "$f" "$dir/$newbase" +done +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4f95b90 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/zakirullin/files.md + +go 1.24 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.2-0.20221020003552-4126fa611266 + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/robfig/cron/v3 v3.0.0 + github.com/spf13/afero v1.9.5 + github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.23.0 + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 + golang.org/x/net v0.25.0 // indirect + golang.org/x/text v0.16.0 +) + +require github.com/rivo/uniseg v0.4.7 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7b134f --- /dev/null +++ b/go.sum @@ -0,0 +1,466 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.2-0.20221020003552-4126fa611266 h1:B1MTo1Xwp/SNvUOGxo7E95vIDXRYIJyF787suIZq9mU= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.2-0.20221020003552-4126fa611266/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E= +github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/server/bot.go b/server/bot.go new file mode 100644 index 0000000..cf3452d --- /dev/null +++ b/server/bot.go @@ -0,0 +1,2901 @@ +// Bot's main functionality. We accept messages from the user, +// we ask user where to save the messages. We save messages +// to plain markdown files locally. + +package server + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "golang.org/x/exp/slog" + + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/habits" + "github.com/zakirullin/files.md/server/i18n" + "github.com/zakirullin/files.md/server/journal" + "github.com/zakirullin/files.md/server/pkg/slice" + "github.com/zakirullin/files.md/server/pkg/tg" + "github.com/zakirullin/files.md/server/pkg/txt" + "github.com/zakirullin/files.md/server/plugins" + "github.com/zakirullin/files.md/server/stats" + "github.com/zakirullin/files.md/server/sync" + "github.com/zakirullin/files.md/server/userconfig" +) + +var ( + errUnknownCommand = errors.New("unknown command") + errInvalidRequestFromInline = errors.New("invalid request from inline query") + errInvalidInlineQuery = errors.New("invalid inline query") + BotPlugins = []BotPlugin{plugins.NewWorldClockPlugin()} +) + +const ( + btnsPerRow = 3 + quickBtnsPerRow = 4 + maxBtns = 50 + maxBtnsInChecklist = 10 // For _read_ and _watch_ checklists, so we're less likely to be overwhelmed :) + maxGroupedBtnsInMoveTo = 6 + maxInlineResults = 20 + maxMsgLength = 4096 // In UTF-8 characters (runes), skin-tone emojis count as 2 + maxMsgsToSendAtOnce = 5 // For lengthy messages + maxHeaderLength = 100 + maxHeaderLengthForMobile = 33 // Fits regular mobile screen + inlineResultsCacheTime = 15 // Seconds + + // On mobile phones buttons shrink to the message width, and sometimes it's too narrow, so we make the message wider + wideSpacer = "" +) + +// Update represents incoming user updates. +type Update interface { + MsgText() string + UserID() int64 + Cmd() *tg.Cmd + MsgEntities() []tgbotapi.MessageEntity + CaptionEntities() []tgbotapi.MessageEntity + CallbackQueryID() (string, bool) + InlineQueryID() (string, bool) + InlineQuery() (string, bool) + InlineQueryOffset() int + IsSentViaBot() bool + ReplyToMsgID() (int, bool) + PhotoOrImageID() (string, bool) + Caption() string + MsgID() (int, bool) + Time() (int, bool) + ChannelID() (int64, bool) + ChannelName() (string, bool) +} + +// Chat provides a simple interface to chat API like Telegram. +type Chat interface { + Send(userID int64, text string, kb *tg.Keyboard, markup string) (int, error) + SendImages(userID int64, images []string) ([]int, error) + SendReaction(userID int64, msgID int, reaction string) error + Edit(userID int64, msgID int, text string, kb *tg.Keyboard, markup string) error + Del(userID int64, msgID int) error + AnswerCallbackQuery(queryID string, text string) error + AnswerInlineQuery(queryID string, results []interface{}, cacheTime int, offset string) error + DownloadFile(fileID string, outFile io.Writer) (string, error) +} + +// Database stores per user data like "last sent message id" +type Database interface { + LastKeyboardMsgID() (int, bool) + SetLastKeyboardMsgID(ID int) + DelLastKeyboardMsgID() + InputExpectation() *tg.Cmd + SetInputExpectation(cmd tg.Cmd) + DelInputExpectation() + HashOrPathByMsgID(msgID int) (string, bool) + SetHashOrPathByMsgID(msgID int, value string) + RecentCommand() (string, bool) + SetRecentCommand(cmd string) + RecentCommandParams() ([]string, bool) + SetRecentCommandParams(params []string) + AddImgMsgID(msgID int) + ImgMsgID() ([]int, bool) + DelImgMsgID() +} + +type BotPlugin interface { + CanHandle(string) bool + Handle(string) (output string, error error) +} + +var now = time.Now + +// Telegram only allows 64 bytes in callback_data, +// So we have to be really short :) +const ( + CmdShowStart = "start" + CmdDoNothing = "nothing" + CmdShowLater = "later" + CmdShowHome = "home" + CmdShowFiles = "files" + CmdShowDirs = "dirs" + CmdShowPostpone = "postpone" + CmdShowMoveExisting = "move" + CmdShowMoveTo = "s_move" + CmdShowRename = "rename" + CmdShowRenameFile = "rename_file" + CmdShowChecklists = "checklists" + CmdShowStats = "stats" + CmdOpenInApp = "app" + CmdShowHelp = "help" + CmdComplete = "c" + CmdPostpone = "post" + CmdShowLongItemFromChecklist = "item" + CmdShowLongItem = "item_t" + CmdShowFile = "file" + CmdShowChecklist = "checklist" + CmdShowChecklistItem = "check_show" + CmdCompleteListItem = "check_comp" + CmdShowMoveToDirOrFile = "to_file" + CmdShowMoveToChecklist = "to_checklist" + CmdRename = "ren" + CmdMoveToExistingDir = "mv" + CmdMoveToChecklist = "add_item" + CmdCompleteChecklistItem = "check_item" + CmdRequestNewDir = "new_dir" + CmdMoveToNewDir = "mv_to_new_dir" + CmdMoveToExistingFile = "mf" + CmdMoveToExistingNote = "mvn" + CmdMoveToNewFile = "mn" + CmdMoveToDirChecklist = "mv_to_chk" + CmdMoveToRead = "mv_to_read" + CmdMoveToWatch = "mv_to_watch" + CmdMoveToShop = "mv_to_shop" + CmdMoveToNewChecklist = "mv_to_new_chk" + CmdMoveToJournal = "mv_to_journal" + CmdMoveToLater = "mv_later" + CmdShowScheduleForDay = "sc_day" + CmdSchedule = "sc" + CmdScheduleForTmrw = "sc_tmrw" + CmdPomodoro = "pomodoro" + CmdShowScheduleForDayRecurring = "sc_day_r" + CmdLater = "later" + CmdShowSettings = "settings" + CmdShowQuickBtnsSettings = "c_quick_btns" + CmdShowMoveToBtnsSettings = "c_move_btns" + CmdAddToQuickBtns = "add_quick" + CmdDelFromQuickBtns = "del_quick" + CmdAddToMoveToBtns = "add_move" + CmdDelFromMoveToBtns = "del_move" + CmdShowTimezone = "timezone" + CmdSetTimezone = "set_timezone" + CmdShowReadChecklist = "read" + CmdShowWatchChecklist = "watch" + CmdShowShopChecklist = "shop" + CmdShowSchedule = "schedule" + CmdDownload = "download" + CmdTasksOnlyMode = "tasks_only" + CmdNotesOnlyMode = "notes_only" + CmdJournalOnlyMode = "journal_only" + CmdFullMode = "full" + CmdChatMode = "chat" + CmdInlineQuerySearchEveryWhere = "search" + CmdWebAppHabits = "habits" + CmdRandomNote = "random_note" + CmdAddToJournalShortcut = "j" + CmdAddToJournalAndContinueShortcut = "ja" + CmdAddToRecentFileShortcut = "+" + CmdCompleteHabit = "ch" + CmdShare = "share" +) + +var Shortcuts = map[string][]string{ + CmdAddToJournalShortcut: {"/ж", "jj", "жж"}, + CmdAddToJournalAndContinueShortcut: {"жд", "jd", "ja"}, + CmdAddToRecentFileShortcut: {"++"}, +} + +// Bot has all the things that we need to handle a message or command from a user. +// We use tg chat to talk with the user. +// We use fs to save artefacts to the disk (.md files). +// We use db to save temporal things like recent command. +// We use cfg to configure bot behaviour (config.json). +type Bot struct { + userID int64 + tg Chat + fs *fs.FS + db Database + cfg *userconfig.Config +} + +func NewBot(userID int64, tg Chat, fs *fs.FS, db Database, cfg *userconfig.Config) *Bot { + return &Bot{userID, tg, fs, db, cfg} +} + +// Reply to incoming text message, command or inline query +func (b *Bot) Reply(u Update) error { + // Handle inline queries. + if _, ok := u.InlineQueryID(); ok { + return b.answerSearch(u) + } + + // Handle messages from channels. + _, isChannel := u.ChannelID() + if isChannel { + channelName, _ := u.ChannelName() + if len(strings.TrimSpace(channelName)) == 0 { + channelName = "UnknownChannel" + } + + return b.addToFile(fs.DirUserRoot, fs.Filename(channelName), u.MsgText()) + } + + // Handle plugins. + for _, plugin := range BotPlugins { + if plugin.CanHandle(u.MsgText()) { + output, err := plugin.Handle(u.MsgText()) + if err != nil { + return fmt.Errorf("answer: plugin error: %w", err) + } + _, _ = b.tg.Send(b.userID, output, nil, tg.MarkupHTML) + + b.delAllKeyboards() + err = b.ShowHome(nil) + if err != nil { + return fmt.Errorf("answer after plugin: %w", err) + } + + return nil + } + } + + // Handle inline query file requests + if u.IsSentViaBot() { + return b.answerFileRequest(u.MsgText()) + } + + // Handle commands + cmd, err := b.extractCmd(u) + if err != nil { + return fmt.Errorf("answer: %w", err) + } + if cmd != nil { + if _, ok := u.CallbackQueryID(); !ok { + b.delAllKeyboards() + } + + handler, ok := b.handlers()[cmd.Name] + if !ok { + // It should be handled at cmd extraction step + return fmt.Errorf("no such command %s: %w", cmd.Name, errUnknownCommand) + } + slog.Debug("Command is called", "command", cmd.Name, "params", cmd.Params) + err = handler(cmd.Params) + if err != nil { + return err + } + + if callbackQueryID, ok := u.CallbackQueryID(); ok { + // We can tolerate an error here, that won't affect UX + if cmd.Name == CmdCompleteHabit || cmd.Name == CmdComplete { + _ = b.tg.AnswerCallbackQuery(callbackQueryID, completedMsg()) + } else if cmd.Name == CmdShare { + _ = b.tg.AnswerCallbackQuery(callbackQueryID, "Shared 💚!") + } else { + _ = b.tg.AnswerCallbackQuery(callbackQueryID, "") + } + } + + return nil + } + + // Handle images. + if _, hasImage := u.PhotoOrImageID(); hasImage { + err = b.saveFromImage(u) + } else { + err = b.saveFromTextMsg(u) + } + + if errors.Is(err, fs.ErrQuotaExceeded) { + b.tg.Send(b.userID, "Storage quota exceeded. Please delete some files.", nil, tg.MarkupHTML) + return nil + } + + return err +} + +// Commands and their handlers. +// Every handler accepts []string params +func (b *Bot) handlers() map[string]func([]string) error { + handlers := map[string]func([]string) error{ + // Direct user commands + CmdShowHome: b.ShowHome, + CmdShowStart: b.showStart, + CmdShowLater: b.showLaterTasks, + CmdShowFiles: b.showFiles, + CmdShowDirs: b.showDirs, + CmdShowChecklists: b.showChecklists, + CmdShowPostpone: b.showPostpone, + CmdShowMoveTo: b.showMoveTo, + CmdShowRename: b.showRename, + CmdShowStats: b.showStats, + CmdShowReadChecklist: b.showRead, + CmdRandomNote: b.randomNote, + CmdShowWatchChecklist: b.showWatch, + CmdShowShopChecklist: b.showShop, + CmdShowSchedule: b.showSchedule, + CmdShowMoveExisting: b.showMoveExisting, + CmdShowSettings: b.showSettings, + CmdShowTimezone: b.showTimezone, + CmdSetTimezone: b.setTimezone, + CmdOpenInApp: b.openInApp, + CmdShowHelp: b.showHelp, + CmdDownload: b.download, + // Button's commands (callbacks) + CmdShowRenameFile: b.showRenameFile, + CmdShowLongItemFromChecklist: b.showLongItemFromChecklist, + CmdShowLongItem: b.showLongItem, + CmdShowFile: b.showFile, + CmdShowChecklist: b.showChecklist, + CmdCompleteListItem: b.completeListItem, + CmdShowChecklistItem: b.showChecklistItem, + CmdShowScheduleForDay: b.showToADay, + CmdShowMoveToDirOrFile: b.showMoveToFileOrDir, + CmdShowMoveToChecklist: b.showToChecklist, + CmdMoveToExistingDir: b.moveToDir, + CmdMoveToChecklist: b.moveToChecklist, + CmdCompleteChecklistItem: b.completeChecklistItem, + CmdRequestNewDir: b.requestNewDirName, + CmdMoveToNewDir: b.moveToNewDir, + CmdMoveToExistingFile: b.moveToExistingFile, + CmdMoveToExistingNote: b.moveToExistingNote, + CmdMoveToNewFile: b.moveToNewFile, + CmdMoveToDirChecklist: b.moveToDirChecklist, + CmdMoveToRead: b.moveToRead, + CmdMoveToWatch: b.moveToWatch, + CmdMoveToShop: b.moveToShop, + CmdMoveToNewChecklist: b.moveToNewChecklist, + CmdMoveToJournal: b.moveToJournal, + CmdMoveToLater: b.moveToLater, + CmdSchedule: b.schedule, + CmdScheduleForTmrw: b.scheduleForTmrw, + CmdComplete: b.complete, + CmdPostpone: b.postpone, + CmdPomodoro: b.togglePomodoro, + CmdShowScheduleForDayRecurring: b.showToADayRecurring, + CmdShowQuickBtnsSettings: b.showQuickBtnsSettings, + CmdShowMoveToBtnsSettings: b.showMoveToBtnsSettings, + CmdAddToQuickBtns: b.addToQuickBtns, + CmdDelFromQuickBtns: b.delFromQuickBtns, + CmdAddToMoveToBtns: b.addToMoveToBtns, + CmdDelFromMoveToBtns: b.delFromMoveToBtns, + CmdAddToJournalShortcut: b.addToJournalFromShortcut, + CmdAddToJournalAndContinueShortcut: b.addToJournalAndContinue, + CmdAddToRecentFileShortcut: b.addToRecentFileOrNoteFromShortcut, + CmdRename: b.rename, + CmdTasksOnlyMode: b.setTasksOnlyMode, + CmdNotesOnlyMode: b.setNotesOnlyMode, + CmdJournalOnlyMode: b.setJournalOnlyMode, + CmdFullMode: b.setFullMode, + CmdChatMode: b.setChatOnlyMode, + CmdCompleteHabit: b.completeHabit, + CmdShare: b.shareNote, + // Used for button-like separators + CmdDoNothing: func(s []string) error { return nil }, + } + + for cmd, shortcuts := range Shortcuts { + for _, shortcut := range shortcuts { + handlers[shortcut] = handlers[cmd] + } + } + + return handlers +} + +func (b *Bot) extractCmd(u Update) (*tg.Cmd, error) { + cmd := u.Cmd() + if cmd != nil { + // Check if the command is known + _, ok := b.handlers()[cmd.Name] + if !ok { + _, _ = b.tg.Send(b.userID, i18n.Tr("I know nothing about this command 😕"), nil, tg.MarkupHTML) + return nil, fmt.Errorf("unknown command: %s", cmd.Name) + } + + b.db.DelInputExpectation() + + return cmd, nil + } + + // Input expectation is mostly used for renaming things + cmd = b.db.InputExpectation() + if cmd != nil { + slog.Debug("Got command from input expectation", "command", cmd.Name) + b.db.DelInputExpectation() + + for i, param := range cmd.Params { + if param == "%s" { + cmd.Params[i] = u.MsgText() + } + } + + return cmd, nil + } + + for canonicalCMD, shortcuts := range Shortcuts { + for _, shortcut := range shortcuts { + escapedShortcut := regexp.QuoteMeta(shortcut) + reText := regexp.MustCompile(fmt.Sprintf(`(?i)^%s\s+|\s+%s$`, escapedShortcut, escapedShortcut)) + // The only difference from reText is that caption can contain only shortcut, with no other text + reCaption := regexp.MustCompile(fmt.Sprintf(`(?i)^%s\s+|\s+%s$|^\s*%s\s*$`, escapedShortcut, escapedShortcut, escapedShortcut)) + + doesntMatchText := !reText.MatchString(u.MsgText()) + doesntMatchCaption := !reCaption.MatchString(u.Caption()) + if doesntMatchText && doesntMatchCaption { + continue + } + + text := "" + _, hasImage := u.PhotoOrImageID() + if hasImage { + var errImage error + text, errImage = b.saveImage(u) + if errImage != nil { + return nil, fmt.Errorf("save image: %w", errImage) + } + text = string(reCaption.ReplaceAll([]byte(text), []byte(""))) + } else { + text = extractMarkdown(u) + text = string(reText.ReplaceAll([]byte(text), []byte(""))) + } + + text = txt.Ucfirst(strings.TrimSpace(text)) + shortCmd := tg.NewCmd(canonicalCMD, []string{text}) + + return &shortCmd, nil + } + } + + return nil, nil +} + +func (b *Bot) saveFromTextMsg(u Update) error { + msg := extractMarkdown(u) + if len(msg) == 0 { + return fmt.Errorf("save: empty message") + } + + // Collapse a few consecutive messages into one, see bot_forwards.go + msgTime, updateHasTime := u.Time() + if updateHasTime { + _, shouldCollapse := collapseToMsg(b.userID, msgTime) + if shouldCollapse { + // We just write at the end of our append-only chat file, + // that would concat the current message with the previous one. + err := b.createOrAdd(fs.DirUserRoot, fs.ChatFilename, msg) + if err != nil { + return fmt.Errorf("save collapsed: %w", err) + } + return nil + } + } + + // Adding to an existing file or chat item + if replyMsgID, ok := u.ReplyToMsgID(); ok { + return b.addToReplied(replyMsgID, msg) + } + + msgHash, err := b.appendToChat(msg, b.cfg.Timezone()) + if err != nil { + return fmt.Errorf("save to chat: %w", err) + } + + if b.cfg.ChatOnlyMode() { + msgID, _ := u.MsgID() + _ = b.tg.SendReaction(b.userID, msgID, "👌") + return nil + } + + if updateHasTime { + setFirstMsgHash(b.userID, msgHash, msgTime) + setFirstMsgTime(b.userID, msgTime) + } + + if b.cfg.JournalOnlyMode() { + return b.moveToJournal([]string{msgHash}) + } + + return b.showMoveTo([]string{msgHash}) +} + +// TODO test collapsing from both regular messages and images +func (b *Bot) saveFromImage(u Update) error { + content, err := b.saveImage(u) + if err != nil { + return fmt.Errorf("save from image: %w", err) + } + + // Collapse a few consecutive messages into one, see bot_forwards.go + msgTime, updateHasTime := u.Time() + if updateHasTime { + _, shouldCollapse := collapseToMsg(b.userID, msgTime) + if shouldCollapse { + err := b.createOrAdd(fs.DirUserRoot, fs.ChatFilename, content) + if err != nil { + return fmt.Errorf("save collapsed: %w", err) + } + return nil + } + } + + // Adding to an existing file or chat item + if replyMsgID, ok := u.ReplyToMsgID(); ok { + return b.addToReplied(replyMsgID, content) + } + + msgHash, err := b.appendToChat(content, b.cfg.Timezone()) + if err != nil { + return fmt.Errorf("save from image: %w", err) + } + + if b.cfg.ChatOnlyMode() { + msgID, _ := u.MsgID() + // We can tolerate missing reaction. + _ = b.tg.SendReaction(b.userID, msgID, "👌") + return nil + } + + // Track forwards. + if updateHasTime { + setFirstMsgHash(b.userID, msgHash, msgTime) + setFirstMsgTime(b.userID, msgTime) + } + + if b.cfg.JournalOnlyMode() { + return b.moveToJournal([]string{msgHash}) + } + + return b.showMoveTo([]string{msgHash}) +} + +// saveImage saves an image to the filesystem and returns a markdown link to it +func (b *Bot) saveImage(u Update) (string, error) { + imageID, _ := u.PhotoOrImageID() + + var buf bytes.Buffer + extension, err := b.tg.DownloadFile(imageID, &buf) + if err != nil { + return "", fmt.Errorf("can't download file: %w", err) + } + + imgFilename := fmt.Sprintf("tg_%s%s", imageID, extension) + err = b.fs.Write(fs.DirMedia, imgFilename, buf.String()) + if err != nil { + return "", fmt.Errorf("can't save image: %w", err) + } + + // TODO remove center + imgPath := fmt.Sprintf("%s/%s", fs.DirMedia, imgFilename) + content := fmt.Sprintf("![](%s)", imgPath) + // If there's caption, place it under the image + if u.Caption() != "" { + caption := txt.TelegramEntitiesToMarkdown(u.Caption(), u.CaptionEntities()) + caption = strings.TrimSpace(txt.NormNewLines(caption)) + content = fmt.Sprintf("%s\n%s", content, txt.Ucfirst(caption)) + } + + return content, nil +} + +// addToReplied appends newContent to whatever the bot rendered for +// replyToMsgID. Chat-item targets are stored with a "#" prefix +// ("#"); file targets are stored as a plain relative path. +func (b *Bot) addToReplied(replyToMsgID int, newContent string) error { + value, ok := b.db.HashOrPathByMsgID(replyToMsgID) + if !ok { + return fmt.Errorf("add to replied: no target for msgID %d", replyToMsgID) + } + + if strings.HasPrefix(value, "#") { + msgHash := strings.TrimPrefix(value, "#") + chatMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return fmt.Errorf("add to replied chat: can't read chat: %w", err) + } + updated, err := appendToChatMsg(chatMD, msgHash, newContent) + if err != nil { + return fmt.Errorf("add to replied chat: %w", err) + } + if err := b.fs.Write(fs.DirUserRoot, fs.ChatFilename, updated); err != nil { + return fmt.Errorf("add to replied chat: can't write chat: %w", err) + } + b.delAllKeyboards() + return b.ShowHome(nil) + } + + // File case: value is a relative path under user root. fs.SafePath + // accepts the whole thing as `filename` when dir is the user root. + existingContent, err := b.fs.Read(fs.DirUserRoot, value) + if err != nil { + return fmt.Errorf("add: can't read: %w", err) + } + + content := strings.TrimRight(existingContent, "\n") + "\n\n" + strings.TrimSpace(newContent) + "\n" + if err := b.fs.Write(fs.DirUserRoot, value, content); err != nil { + return fmt.Errorf("add: can't write: %w", err) + } + + b.delAllKeyboards() + + b.db.SetRecentCommand(CmdMoveToExistingFile) + b.db.SetRecentCommandParams([]string{fs.ShortHash(value)}) + + return b.ShowHome(nil) +} + +func (b *Bot) answerSearch(u Update) error { + query, ok := u.InlineQuery() + if !ok { + return nil + } + query = strings.TrimSpace(query) + + if strings.Contains(query, "../") || strings.Contains(query, "/..") { + return fmt.Errorf("insecure input '%s': %w", query, errInvalidInlineQuery) + } + + matchedNotes, err := b.fs.SearchFilesByName(query) + if err != nil { + return fmt.Errorf("inline reply: %w", err) + } + if u.InlineQueryOffset() >= len(matchedNotes) { + return nil + } + maxIndex := min(u.InlineQueryOffset()+maxInlineResults, len(matchedNotes)) + matchedNotes = matchedNotes[u.InlineQueryOffset():maxIndex] + + var results []interface{} + for id, note := range matchedNotes { + // Nested files: show the relative path without leading "/" so the + // search result reads like "happiness/sub/Note.md". + path := note.Name + if note.ParentDir != fs.DirUserRoot && note.ParentDir != "" { + path = fmt.Sprintf("%s/%s", note.ParentDir, note.Name) + } + article := tgbotapi.NewInlineQueryResultArticleHTML(strconv.Itoa(id), note.DisplayName, path) + results = append(results, article) + } + + queryID, _ := u.InlineQueryID() + nextOffset := strconv.Itoa(u.InlineQueryOffset() + maxInlineResults) + + // First element is usually the file itself, exclude it + if len(query) == 0 { + results = results[1:] + } + + err = b.tg.AnswerInlineQuery(queryID, results, inlineResultsCacheTime, nextOffset) + // FakeTG library has a bug of unmarshalling sent result, we'll mute that temporarily + if err != nil && !strings.HasSuffix(err.Error(), "Go value of type tgbotapi.Message") { + return fmt.Errorf("inline reply: %w", err) + } + + return nil +} + +func (b *Bot) answerFileRequest(msg string) error { + if strings.Contains(msg, "../") || strings.Contains(msg, "/..") { + return fmt.Errorf("insecure input '%s': %w", msg, errInvalidRequestFromInline) + } + + // Split on the FIRST slash: dir is the top-level directory (what + // Unhash resolves against root), path is everything after it. Lets + // nested inline-search results like + // "triggers/habits/insights/2022 Habits.md" parse as + // (dir="triggers", path="habits/insights/2022 Habits.md"). + msg = strings.TrimSpace(msg) + var dir, path string + if idx := strings.Index(msg, "/"); idx == -1 { + dir = fs.DirUserRoot + path = msg + } else { + dir = strings.TrimSpace(msg[:idx]) + path = strings.TrimSpace(msg[idx+1:]) + } + if path == "" { + return fmt.Errorf("invalid inline query '%s': %w", msg, errInvalidRequestFromInline) + } + + b.delAllKeyboards() + + // TODO add tests + // User wants to add his text to a selected file + c := b.db.InputExpectation() + if c != nil { + b.db.DelInputExpectation() + msgHash := c.Params[0] + + err := b.moveFromChat(func(content string, timestamp time.Time) error { + if dir == fs.DirUserRoot { + // We have a file + b.db.SetRecentCommand(CmdMoveToExistingFile) + b.db.SetRecentCommandParams([]string{fs.ShortHash(path)}) + } else { + // We have a note (a file placed in a subdirectory) + b.db.SetRecentCommand(CmdMoveToExistingNote) + b.db.SetRecentCommandParams([]string{fs.ShortHash(path), fs.ShortHash(dir)}) + } + + err := b.addToFile(dir, path, content) + if err != nil { + return fmt.Errorf("inline query: can't add to file %s: %w", path, err) + } + + return nil + }, false, msgHash) + if err != nil { + return fmt.Errorf("inline query: can't move from chat: %w", err) + } + + // Just an informative message + _, _ = b.tg.Send(b.userID, fmt.Sprintf(i18n.Tr("Saved to %s"), fs.DisplayName(path)), nil, tg.MarkupHTML) + + return b.ShowHome(nil) + } + + return b.showFile([]string{dir, path}) +} + +func (b *Bot) createOrAdd(dir, filename, content string) error { + exists, err := b.fs.Exists(dir, filename) + if err != nil { + return fmt.Errorf("create: %w", err) + } + + if exists { + existingContent, err := b.fs.Read(dir, filename) + if err != nil { + return fmt.Errorf("create: %w", err) + } + existingContent = strings.TrimSpace(existingContent) + + if len(existingContent) != 0 { + content = fmt.Sprintf("%s\n%s", strings.TrimSpace(existingContent), content) + } + } + + if err := b.fs.Write(dir, filename, content); err != nil { + return fmt.Errorf("create: %w", err) + } + + return nil +} + +func (b *Bot) extractHeaderAndBody(msg string, maxHeaderLen int) (string, string, error) { + if len(msg) == 0 { + return "", "", fmt.Errorf("extract title: empty msg") + } + + parts := strings.Split(msg, "\n") + title := txt.Ucfirst(strings.TrimSpace(parts[0])) + if txt.HasImage(title) { + if len(parts) > 1 { + title = txt.Ucfirst(strings.TrimSpace(parts[1])) + } + + if title == "" || len(parts) == 1 { + title = fmt.Sprintf("Img %s", now().Format("02.01.06 15:04")) + } + } + + if utf8.RuneCountInString(title) > maxHeaderLen { + title = txt.Substr(title, 0, maxHeaderLen) + "..." + } + + sanitizedTitle := fs.SanitizeFilename(title) + content := msg + // If title is the same as content, we don't need to save it + if sanitizedTitle == content { + content = "" + } + // If title is already in the content, remove it. + // See bot.restoreMsg() to see how the message is restored. + if strings.HasPrefix(content, sanitizedTitle) { + content = strings.TrimSpace(strings.TrimPrefix(content, sanitizedTitle)) + } + + return sanitizedTitle, content, nil +} + +// If content is empty, use its filename as content. +// If file has content, add filename to the beginning of the content. +// If file has content, and filename was truncated (...), no need to add filename. +// If file has image and caption underneath it, no need to add title. +// The ugliest method so far. +func (b *Bot) restoreMsg(dir, filename string) (string, error) { + msg, err := b.fs.Read(dir, filename) + if err != nil { + return "", fmt.Errorf("can't restore msg for '%s': %w", filename, err) + } + + title := fs.DisplayName(filename) + nonTruncatedTitle := strings.TrimRight(title, "...") + sanitizedContent := strings.ToLower(fs.SanitizeFilename(msg)) + contentHasNoTitle := !strings.HasPrefix(sanitizedContent, strings.ToLower(nonTruncatedTitle)) + hasNoImg := !txt.HasImage(msg) + if len(msg) == 0 { + return title, nil + } else if contentHasNoTitle && hasNoImg { + return fmt.Sprintf("%s\n%s", title, msg), nil + } + + // msg has all the information, title doesn't have anything to add + return msg, nil +} + +func (b *Bot) tr(str string, args ...any) string { + str = i18n.Tr(str) + + return fmt.Sprintf(str, args...) +} + +// Replace last message + keyboard with the new one +// Or show the new one (in case of wimagehoto). +func (b *Bot) showHTML(validHTML string, kb *tg.Keyboard) error { + b.delAllImages() + + mid, hasLastKeyboard := b.db.LastKeyboardMsgID() + if !hasLastKeyboard { + b.delAllKeyboards() + + mid, err := b.tg.Send(b.userID, validHTML, kb, tg.MarkupHTML) + if err != nil { + return fmt.Errorf("show: %w", err) + } + + b.db.SetLastKeyboardMsgID(mid) + + return nil + } + + return b.tg.Edit(b.userID, mid, validHTML, kb, tg.MarkupHTML) +} + +// Replace last message + keyboard with the new ones +// Or show the new one (in case of image). +// Read "Markdown to HTML conversion" section in readme's ADRs +// Chat allows 1-4096 characters AFTER entities parsing, +// meaning we can have 4096 plain chars + any amount of tags. +func (b *Bot) showMD(probablyInvalidMD string, kb *tg.Keyboard) error { + b.delAllImages() + + probablyInvalidMD, images, links := txt.ExtractTextImgsLinks(probablyInvalidMD) + + for label, link := range links { + dir := fs.DirUserRoot + link = strings.TrimSpace(link) + parts := strings.SplitN(link, "/", 2) + if len(parts) == 2 { + dir = parts[0] + link = parts[1] + } + + cmd := tg.NewCmd(CmdShowFile, []string{fs.Hash(dir), fs.Hash(link)}) + kb.PrependRow(tg.NewRow(tg.NewBtn(txt.Ucfirst(label), cmd))) + } + + mid, hasLastKeyboard := b.db.LastKeyboardMsgID() + textChunks := txt.SplitTextIntoChunks(probablyInvalidMD, maxMsgLength) + if !hasLastKeyboard || len(textChunks) > 1 || len(images) > 0 { + b.delAllKeyboards() + + // Sending a gallery of images if there are any + if len(images) > 0 { + // We tolerate errors with the image gallery for now, text is more important + mids, imgErr := b.tg.SendImages(b.userID, images) + if imgErr == nil { + for _, imgMid := range mids { + b.db.AddImgMsgID(imgMid) + } + } else { + slog.Error("Can't send images", "error", imgErr) + } + } + + // If our msg is too long, we send maxMsgsToSendAtOnce first messages. + // Keyboard is attached to the last one + textChunks = textChunks[0:min(maxMsgsToSendAtOnce, len(textChunks))] + lastChunk := textChunks[len(textChunks)-1] + textChunks = textChunks[0 : len(textChunks)-1] + for _, textChunk := range textChunks { + _, _ = b.tg.Send(b.userID, txt.MarkdownToHTML(textChunk), nil, tg.MarkupHTML) + } + + mid, err := b.tg.Send(b.userID, txt.MarkdownToHTML(lastChunk), kb, tg.MarkupHTML) + if err != nil { + return fmt.Errorf("show: %w", err) + } + + b.db.SetLastKeyboardMsgID(mid) + + return nil + } + + return b.tg.Edit(b.userID, mid, txt.MarkdownToHTML(probablyInvalidMD), kb, tg.MarkupHTML) +} + +func (b *Bot) showMoveTo(params []string) error { + msgHash := params[0] + + if b.cfg.NotesOnlyMode() { + b.delAllKeyboards() + + return b.showMoveToFileOrDir([]string{msgHash}) + } + + var kb tg.Keyboard + userMoveToBtns := b.moveToBtns(msgHash) + if len(userMoveToBtns) == 0 { + b.delAllKeyboards() + + return b.ShowHome(nil) + } + + // Add recent command if any + recentBtn := b.recentCmdBtn(msgHash) + if recentBtn != nil { + userMoveToBtns = append(userMoveToBtns, *recentBtn) + } + + // This command is "do nothing and leave an item in the inbox" + if !b.cfg.TasksOnlyMode() { + showTodayCmd := tg.NewCmd(CmdShowHome, []string{}) + showTodayLabel := "👌" + userMoveToBtns = append(userMoveToBtns, tg.NewBtn(showTodayLabel, showTodayCmd)) + } + + userBtnsByRows := slice.Chunk(userMoveToBtns, btnsPerRow) + for _, row := range userBtnsByRows { + kb.AddRow(row) + } + + b.delAllKeyboards() + + msg := b.tr("Saved!") + if err := b.showHTML(msg, &kb); err != nil { + return fmt.Errorf("move: %w", err) + } + + return nil +} + +func (b *Bot) recentCmdBtn(msgHash string) *tg.Btn { + recentCmd, ok := b.db.RecentCommand() + if !ok { + return nil + } + + args, _ := b.db.RecentCommandParams() + args = append(args, msgHash) + targetFilenameHash := args[0] + + var unhashedTarget string + icon := "⭐️" + if recentCmd == CmdMoveToExistingFile { + var err error + unhashedTarget, err = b.fs.Unhash(fs.DirUserRoot, targetFilenameHash) + if err != nil { + return nil + } + } else if recentCmd == CmdMoveToExistingNote { + dir, err := b.fs.Unhash(fs.DirUserRoot, args[1]) + if err != nil { + return nil + } + + unhashedTarget, err = b.fs.Unhash(dir, targetFilenameHash) + if err != nil { + return nil + } + } else { + return nil + } + + name := fmt.Sprintf("%s %s", icon, fs.DisplayName(unhashedTarget)) + btn := tg.NewBtn(name, tg.NewCmd(recentCmd, args)) + return &btn +} + +func (b *Bot) ShowHome(_ []string) error { + if b.cfg.NotesOnlyMode() { + return b.showDirs(nil) + } + + if b.cfg.JournalOnlyMode() || b.cfg.ChatOnlyMode() { + _, err := b.tg.Send(b.userID, i18n.Tr("What's on your mind?"), nil, tg.MarkupHTML) + if err != nil { + return fmt.Errorf("show today: can't send journal message: %w", err) + } + return nil + } + + var kb tg.Keyboard + + // Adding records from today + content, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("show today: can't read chat file: %w", err) + } + blocks := readChatMsgs(content) + // Today entry: `- [ ] body` or `- [ ] `HH:MM` body` (timestamp optional). + chatEntryRegex := regexp.MustCompile(`^- \[([ xX])\] (?:` + "`" + `\d{2}:\d{2}` + "` )?") + shownCount := 0 + for _, block := range blocks { + m := chatEntryRegex.FindStringSubmatch(block) + if m == nil { + continue + } + // Skip already-completed entries from the visible list. The hash is + // the same for `[ ]` / `[x]` variants, so tapping the button after a + // completion toggle still resolves to the right line. + if m[1] == "x" || m[1] == "X" { + continue + } + shownCount++ + + msgHash := chatBlockHash(block) + + // Strip the matched prefix (optional checkbox + optional timestamp). + block = strings.TrimSpace(block[len(m[0]):]) + + // Skip image link if any. + parts := strings.Split(block, "\n") + title := txt.Ucfirst(strings.TrimSpace(parts[0])) + if txt.HasImage(title) { + if len(parts) > 1 { + title = txt.Ucfirst(strings.TrimSpace(parts[1])) + } + + if title == "" || len(parts) == 1 { + title = fmt.Sprintf("Img %s", now().Format("02.01.06 15:04")) + } + } + + isMultiline := len(parts) > 1 + if len([]rune(title)) >= maxHeaderLengthForMobile || txt.HasImage(block) || isMultiline { + cmd := tg.NewCmd(CmdShowLongItem, []string{msgHash}) + btn := tg.NewBtn(txt.Emoji(i18n.Emoji("eyes"), title), cmd) + kb.AddRow(btn) + } else { + cmd := tg.NewCmd(CmdComplete, []string{msgHash}) + btn := tg.NewBtn(txt.Emoji(i18n.Emoji(title), title), cmd) + kb.AddRow(btn) + } + } + + // Adding habits + habitsRow := tg.NewRow() + userHabits := make(map[string]habits.Year) + if b.cfg.QuickHabitsEnabled() { + // We can tolerate missing habits + userHabits, _ = habits.LastWeekHabits(b.fs, b.cfg.Timezone()) + _, ok := userHabits[habits.MoodHabit] + if ok { + delete(userHabits, habits.MoodHabit) + } + } + for habit, year := range userHabits { + if completed, _ := year[time.Now().YearDay()]; completed == 1 { + continue + } + + cmd := tg.NewCmd(CmdCompleteHabit, []string{habit}) + habitsRow = append(habitsRow, tg.NewBtn(habits.Emoji(b.fs, habit), cmd)) + } + if len(habitsRow) > 0 { + kb.AddRow(habitsRow) + } + + // Adding quick buttons + quickBtns := b.quickBtns() + if len(quickBtns) > 0 { + quickBtnsByRows := slice.Chunk(quickBtns, quickBtnsPerRow) + for _, row := range quickBtnsByRows { + kb.AddRow(row) + } + } + + msg := b.homeLabel(shownCount) + err = b.showHTML(msg, &kb) + if err != nil { + return fmt.Errorf("show list: %w", err) + } + + return nil +} + +func (b *Bot) showLaterTasks(_ []string) error { + var kb tg.Keyboard + + // Adding tasks from Later.md + laterChecklistMD, err := b.fs.Read(fs.DirUserRoot, fs.LaterFilename) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("show later: can't read later file: %w", err) + } + if len(laterChecklistMD) != 0 { + tasks := txt.IncompleteChecklistItems(laterChecklistMD) + for _, task := range tasks { + cmd := tg.NewCmd(CmdCompleteChecklistItem, []string{fs.Hash(fs.LaterFilename), fs.Hash(task)}) + btn := tg.NewBtn(i18n.AddEmoji(task), cmd) + kb.AddRow(btn) + } + } + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + msg := b.tr("⏳ Your tasks for later:") + err = b.showHTML(msg, &kb) + if err != nil { + return fmt.Errorf("show list: %w", err) + } + + return nil +} + +// TODO improve a bit +// msgsCount - how many messages (inbox items) were shown to a user +func (b *Bot) homeLabel(msgsCount ...int) string { + var statusBar string + + hasPomodoroInToday := false + todayMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err == nil { + _, completed := txt.ChecklistItems(todayMD) + checked, exists := completed[fs.PomodoroTask] + hasPomodoroInToday = exists && !checked + } + if hasPomodoroInToday { + statusBar = i18n.Emoji(fs.DisplayName(fs.PomodoroTask)) + } + + tasksCount := 0 + if len(msgsCount) > 0 && msgsCount[0] > 0 { + tasksCount += msgsCount[0] + } + + if tasksCount == 0 { + statusBar += i18n.Emoji("palm") + } + + if len(statusBar) != 0 { + statusBar += " " + } + + if tasksCount == 0 { + return statusBar + i18n.Tr("Nothing here yet - send me something!") + } + + postfix := "items" + if tasksCount == 1 { + postfix = "item" + } + + return statusBar + fmt.Sprintf(i18n.Tr("%d %s%s"), tasksCount, postfix, wideSpacer) +} + +func (b *Bot) randomNote(_ []string) error { + rootEntries, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return fmt.Errorf("random note: can't get root: %w", err) + } + type note struct { + dir, name string + } + var notes []note + for _, dir := range fs.OnlyNoteDirs(fs.OnlyDirs(rootEntries)) { + entries, err := b.fs.FilesAndDirs(dir.Name) + if err != nil { + return fmt.Errorf("random note: can't get files in %s: %w", dir.Name, err) + } + for _, f := range fs.OnlyUserMDFiles(entries) { + notes = append(notes, note{dir: dir.Name, name: f.Name}) + } + } + if len(notes) == 0 { + return b.ShowHome(nil) + } + pick := notes[rand.Intn(len(notes))] + return b.showFile([]string{fs.Hash(pick.dir), fs.Hash(pick.name)}) +} + +func (b *Bot) showFiles(_ []string) error { + files, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return fmt.Errorf("show files: can't get files: %w", err) + } + + var kb tg.Keyboard + mdFiles := fs.ExcludeConfig(fs.OnlyUserMDFiles(files)) + var fileBtns []tg.Btn + for _, file := range mdFiles { + cmd := tg.NewCmd(CmdShowFile, []string{fs.DirUserRoot, fs.Hash(file.Name)}) + btn := tg.NewBtn(fmt.Sprintf("%s", fs.UnsanitizeFilename(file.DisplayName)), cmd) + fileBtns = append(fileBtns, btn) + } + fileBtnsByRows := slice.Chunk(fileBtns, btnsPerRow) + for _, row := range fileBtnsByRows { + kb.AddRow(row) + } + inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat) + + footer := tg.NewRow(tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd)) + if !b.cfg.NotesOnlyMode() { + footer = append(footer, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + } + kb.AddRow(footer) + + err = b.showHTML(b.tr("📄 Your files:")+wideSpacer, &kb) + if err != nil { + return fmt.Errorf("show files: %w", err) + } + + return nil +} + +func (b *Bot) showDirs(_ []string) error { + files, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return fmt.Errorf("show dirs: can't get dirs: %w", err) + } + + dirs := fs.OnlyNoteDirs(fs.OnlyDirs(files)) + var dirBtns []tg.Btn + for _, dir := range dirs { + cmd := tg.NewCustomCmd("", []string{dir.Name}, tg.CmdTypeInlineQueryCurrentChat) + btn := tg.NewBtn(fmt.Sprintf("%s %s", i18n.Emoji("dir"), dir.DisplayName), cmd) + dirBtns = append(dirBtns, btn) + } + + var kb tg.Keyboard + dirBtnsByRows := slice.Chunk(dirBtns, btnsPerRow) + for _, row := range dirBtnsByRows { + kb.AddRow(row) + } + + inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat) + footer := tg.NewRow(tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd)) + if !b.cfg.NotesOnlyMode() { + footer = append(footer, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + } + kb.AddRow(footer) + + err = b.showHTML(b.tr("🗂 Your dirs:")+wideSpacer, &kb) + if err != nil { + return fmt.Errorf("show dirs: %w", err) + } + + return nil +} + +func (b *Bot) showChecklists(_ []string) error { + checklists, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return fmt.Errorf("show checklists: %w", err) + } + checklists = fs.OnlyChecklists(checklists) + + var kb tg.Keyboard + for _, checklist := range checklists { + cmd := tg.NewCmd(CmdShowChecklist, []string{fs.Hash(checklist.Name)}) + btn := tg.NewBtn(i18n.AddEmoji(checklistTitle(checklist.Name)), cmd) + + kb.AddRow(btn) + } + kb.AddRow(tg.NewBtn(b.tr("🏠 Home"), tg.NewCmd(CmdShowHome, nil))) + + err = b.showHTML(b.tr("☑️ Checklists"), &kb) + if err != nil { + return fmt.Errorf("show checklists: %w", err) + } + + return nil +} + +func (b *Bot) showPostpone(_ []string) error { + var kb tg.Keyboard + + // Inbox items also show in /postpone so the user can send them to Later.md. + inboxMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err == nil { + for _, block := range readChatMsgs(inboxMD) { + if inboxHeaderRegex.MatchString(block) { + continue + } + if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") { + continue + } + preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0] + if len([]rune(preview)) > maxHeaderLengthForMobile { + preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…" + } + cmd := tg.NewCmd(CmdPostpone, []string{chatBlockHash(block)}) + kb.AddRow(tg.NewBtn("💬 "+preview, cmd)) + } + } + + kb.AddRow(tg.NewRow( + tg.NewBtn(b.tr("Rename"), tg.NewCmd(CmdShowRename, []string{})), + tg.NewBtn(b.tr("OK"), tg.NewCmd(CmdShowHome, []string{})), + )) + + err = b.showHTML(b.tr("🦥 Select a task to postpone:"), &kb) + if err != nil { + return fmt.Errorf("show postpone: %w", err) + } + + return nil +} + +func (b *Bot) showMoveExisting(_ []string) error { + var kb tg.Keyboard + + // Show today inbox items + inboxContent, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err == nil { + blocks := readChatMsgs(inboxContent) + for _, block := range blocks { + if inboxHeaderRegex.MatchString(block) { + continue + } + // Skip already-completed entries — they're about to be swept anyway. + if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") { + continue + } + preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0] + if len([]rune(preview)) > maxHeaderLengthForMobile { + preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…" + } + cmd := tg.NewCmd(CmdShowMoveTo, []string{chatBlockHash(block)}) + kb.AddRow(tg.NewBtn("💬 "+preview, cmd)) + } + } + + kb.AddRow(tg.NewRow( + tg.NewBtn(b.tr("Rename"), tg.NewCmd(CmdShowRename, []string{})), + tg.NewBtn(b.tr("OK"), tg.NewCmd(CmdShowHome, []string{})), + )) + + err = b.showHTML(b.tr("🦥 Select an item to move:"), &kb) + if err != nil { + return fmt.Errorf("show move from today: %w", err) + } + + return nil +} + +func (b *Bot) postpone(params []string) error { + hash := params[0] + + err := b.moveFromChat(func(content string, _ time.Time) error { + laterMD, rerr := b.fs.Read(fs.DirUserRoot, fs.LaterFilename) + if rerr != nil && !errors.Is(rerr, os.ErrNotExist) { + return fmt.Errorf("postpone: can't read later file: %w", rerr) + } + return b.fs.Write(fs.DirUserRoot, fs.LaterFilename, txt.AddChecklistItem(laterMD, content, false)) + }, false, hash) + if err != nil { + return fmt.Errorf("postpone: can't move inbox entry to later: %w", err) + } + + return b.showPostpone(nil) +} + +func (b *Bot) showRename(_ []string) error { + var kb tg.Keyboard + + inboxMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err == nil { + for _, block := range readChatMsgs(inboxMD) { + if inboxHeaderRegex.MatchString(block) { + continue + } + if strings.HasPrefix(block, "- [x] ") || strings.HasPrefix(block, "- [X] ") { + continue + } + preview := strings.SplitN(stripInboxEntryPrefix(block), "\n", 2)[0] + if len([]rune(preview)) > maxHeaderLengthForMobile { + preview = string([]rune(preview)[:maxHeaderLengthForMobile]) + "…" + } + cmd := tg.NewCmd(CmdShowRenameFile, []string{fs.ChatFilename, chatBlockHash(block)}) + kb.AddRow(tg.NewBtn("💬 "+preview, cmd)) + } + } + + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + err = b.showHTML(b.homeLabel(), &kb) + if err != nil { + return fmt.Errorf("show rename: %w", err) + } + + return nil +} + +func (b *Bot) showRenameFile(params []string) error { + checklist := params[0] + itemHash := params[1] + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow(tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowHome, []string{}))), + }) + + cmd := tg.NewCmd(CmdRename, []string{checklist, itemHash, "%s"}) + b.db.SetInputExpectation(cmd) + + err := b.showHTML(i18n.Tr("OK. Send me the new name for your task"), kb) + if err != nil { + return fmt.Errorf("show rename: %w", err) + } + + return nil +} + +func (b *Bot) rename(params []string) error { + checklist := params[0] + itemHash := params[1] + newItemNameFromUserInput := params[2] + + md, err := b.fs.Read(fs.DirUserRoot, checklist) + if err != nil { + return fmt.Errorf("rename: can't read checklist %s: %w", checklist, err) + } + + if checklist == fs.ChatFilename { + md, err = renameChatMsg(md, itemHash, newItemNameFromUserInput) + if err != nil { + return fmt.Errorf("rename: %w", err) + } + } else { + md, _ = txt.RemoveChecklistItem(md, itemHash) + md = txt.AddChecklistItem(md, newItemNameFromUserInput, false) + } + + err = b.fs.Write(fs.DirUserRoot, checklist, md) + if err != nil { + return fmt.Errorf("rename: can't write checklist %s: %w", checklist, err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) showStats(_ []string) error { + report, err := stats.TodayReport(b.fs, b.db, b.userID) + if err != nil { + return fmt.Errorf("show stats: %w", err) + } + + kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))}) + err = b.showHTML(strings.TrimSpace(report), kb) + if err != nil { + return fmt.Errorf("show stats: %w", err) + } + + return nil +} + +func (b *Bot) showSchedule(_ []string) error { + scheduledTasks, err := b.cfg.Schedules() + if err != nil { + return fmt.Errorf("show schedule: %w", err) + } + schedule := ScheduleReport(scheduledTasks) + if len(schedule) == 0 { + schedule = i18n.Tr("You don't have any scheduled tasks! 🌴") + } + + kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))}) + err = b.showHTML(schedule, kb) + if err != nil { + return fmt.Errorf("show stats: %w", err) + } + + return nil +} + +func (b *Bot) showRead(_ []string) error { + return b.showChecklist([]string{fs.Hash(fs.ReadFilename)}) +} + +func (b *Bot) showWatch(_ []string) error { + return b.showChecklist([]string{fs.Hash(fs.WatchFilename)}) +} + +func (b *Bot) showShop(_ []string) error { + return b.showChecklist([]string{fs.Hash(fs.ShopFilename)}) +} + +// TODO Chat.md move to today/later +func (b *Bot) showLongItemFromChecklist(params []string) error { + checklistHash := params[0] + itemHash := params[1] + + checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash) + if err != nil { + return fmt.Errorf("complete checklist item: can't unhash checklist %s: %w", checklistHash, err) + } + + checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist) + if err != nil { + return fmt.Errorf("complete checklist item: can't read checklist %s: %w", checklist, err) + } + + item := txt.ChecklistItem(checklistMD, itemHash) + + cmd := CmdShowHome + if checklist == fs.LaterFilename { + cmd = CmdShowLater + } + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn(i18n.StrBack, tg.NewCmd(cmd, []string{})), + tg.NewBtn(i18n.StrComplete, tg.NewCmd(CmdCompleteChecklistItem, []string{checklistHash, itemHash})), + ), + }) + + err = b.showMD(item, kb) + if err != nil { + return fmt.Errorf("show task: %w", err) + } + + return nil +} + +func (b *Bot) showLongItem(params []string) error { + msgHash := params[0] + + chatMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return fmt.Errorf("show long item: can't read inbox file: %w", err) + } + + _, block, ok := findChatMsgByHash(chatMD, msgHash) + if !ok { + return fmt.Errorf("show long item: msgHash %q not found in inbox", msgHash) + } + + // Strip optional `- [ ]` / `- [x] ` prefix + backtick-timestamp prefix. + prefixRegex := regexp.MustCompile(`^(?:- \[[ xX]\] )?` + "`" + `\d{2}:\d{2}` + "`" + ` `) + if loc := prefixRegex.FindStringIndex(block); loc != nil { + block = strings.TrimSpace(block[loc[1]:]) + } + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowHome, []string{})), + tg.NewBtn(i18n.AddEmoji("Move"), tg.NewCmd(CmdShowMoveTo, []string{msgHash})), + tg.NewBtn(txt.Emoji(i18n.Emoji("Archive"), "Complete"), tg.NewCmd(CmdComplete, []string{msgHash})), + ), + }) + + if err := b.showMD(block, kb); err != nil { + return fmt.Errorf("show long item from inbox: %w", err) + } + + // Track msgID → msgHash so a user reply to this rendered chat item + // gets routed back to it via addToRepliedFile's chat-item branch. + msgID, hasLastKeyboard := b.db.LastKeyboardMsgID() + if hasLastKeyboard { + b.db.SetHashOrPathByMsgID(msgID, "#"+msgHash) + } + + return nil +} + +func (b *Bot) showFile(params []string) error { + dirHash := params[0] + filenameHash := params[1] + + dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash) + if err != nil { + return fmt.Errorf("show file: can't find dir: %w", err) + } + + // Inline-search results pass a nested path like + // "habits/insights/2023 Habits.md" as the second param. Unhash only + // resolves immediate children of a dir, so for any value containing + // "/" we take it as a literal relative path. + var filename string + if strings.Contains(filenameHash, "/") { + filename = filenameHash + } else { + filename, err = b.fs.Unhash(dir, filenameHash) + if err != nil { + return fmt.Errorf("show file: can't find file: %w", err) + } + } + + content, err := b.fs.Read(dir, filename) + if err != nil { + return fmt.Errorf("show file: %w", err) + } + + isNotesDir := len(fs.OnlyNoteDirs([]fs.File{{Name: dir}})) > 0 + row := tg.NewRow() + if isNotesDir { + inlineCmd := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat) + row = append(row, tg.NewBtn(i18n.Tr("🔎 Search"), inlineCmd)) + + hasChannelsToPrint := len(b.cfg.Channels()) > 0 + if hasChannelsToPrint { + cmd := tg.NewCmd(CmdShare, []string{fs.Hash(dir), fs.Hash(filename)}) + row = append(row, tg.NewBtn(i18n.Tr("🖨 Share"), cmd)) + } + } + row = append(row, tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + kb := tg.NewKeyboard([]tg.Row{row}) + + md := fmt.Sprintf("**%s**\n\n%s", fs.DisplayName(filename), content) + err = b.showMD(md, kb) + if err != nil { + return fmt.Errorf("show file: %w", err) + } + + msgID, hasLastKeyboard := b.db.LastKeyboardMsgID() + if hasLastKeyboard { + b.db.SetHashOrPathByMsgID(msgID, filepath.ToSlash(filepath.Join(dir, filename))) + } + + return nil +} + +func (b *Bot) showChecklist(params []string) error { + checklistHash := params[0] + + checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash) + if err != nil { + return fmt.Errorf("show checklist: %w", err) + } + + md, err := b.fs.Read(fs.DirUserRoot, checklist) + if err != nil { + return fmt.Errorf("show checklist: %w", err) + } + + items := txt.IncompleteChecklistItems(md) + maxButtons := maxBtns + if checklist == fs.ReadFilename || checklist == fs.WatchFilename { + maxButtons = maxBtnsInChecklist + } + if len(items) > maxButtons { + items = items[:maxButtons] + } + + kb := tg.NewKeyboard(nil) + for _, item := range items { + if len([]rune(item)) >= maxHeaderLengthForMobile { + cmd := tg.NewCmd(CmdShowLongItemFromChecklist, []string{fs.Hash(checklist), fs.Hash(item)}) + btn := tg.NewBtn(txt.Emoji(i18n.Emoji("eyes"), item), cmd) + kb.AddRow(btn) + } else { + cmd := tg.NewCmd(CmdCompleteChecklistItem, []string{checklistHash, fs.Hash(item)}) + btn := tg.NewBtn(i18n.AddEmoji(item), cmd) + kb.AddRow(btn) + } + } + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + title := checklistTitle(checklist) + err = b.showHTML(title+wideSpacer, kb) + if err != nil { + return fmt.Errorf("show checklist: %w", err) + } + + return nil +} + +func (b *Bot) showStart(params []string) error { + // For now it is not used + if len(params) > 0 { + mode := strings.ToLower(params[0]) + if mode == "notes" { + return b.setNotesOnlyMode(nil) + } else if mode == "tasks" { + return b.setTasksOnlyMode(nil) + } else if mode == "journal" { + return b.setJournalOnlyMode(nil) + } else if mode == "full" { + return b.setFullMode(nil) + } + } + + // We can tolerate an error. + _ = b.setFullMode(nil) + + _, err := b.tg.Send(b.userID, "Welcome! 👋\n\nSend me anything, and I’ll save it to files!\n\nClick /app to connect the web app.\n\nBy default Full Mode is enabled, which can feel a bit overwhelming. You can switch to Notes Only or Tasks Only mode in /settings menu.", nil, tg.MarkupHTML) + + return err +} + +func (b *Bot) moveToDir(params []string) error { + // TODO Remove input expectations if dir is not today + toDirHash := params[0] + + msgHashes := strings.Split(params[1], ",") + + toDir, err := b.fs.Unhash(fs.DirUserRoot, toDirHash) + canCreateMissingDir := slices.Contains([]string{fs.DirArchive, fs.DirHabits}, toDirHash) + if err != nil { + if canCreateMissingDir { + // It will be created later in createOrAdd. + toDir = toDirHash + } else { + return fmt.Errorf("move: can't unhash new dir %s: %w", toDir, err) + } + } + + err = b.moveFromChat(func(content string, timestamp time.Time) error { + var sanitizedTitle string + sanitizedTitle, content, err = b.extractHeaderAndBody(content, maxHeaderLength) + if err != nil { + return fmt.Errorf("move to dir from chat: can't extract title and content: %w", err) + } + + filename := fs.Filename(sanitizedTitle) + + notesDir := fs.OnlyNoteDirs([]fs.File{{Name: toDir}}) + isNotesDir := len(notesDir) == 1 + if isNotesDir { + // We can tolerate this, as this is informative logging + _ = journal.AddRecord(b.fs, fmt.Sprintf("📌 %s", fs.DisplayName(filename)), b.cfg.Timezone()) + } + + return b.createOrAdd(toDir, filename, content) + }, true, msgHashes...) + + b.delAllKeyboards() + msg := txt.Emoji(i18n.Emoji("dir"), fmt.Sprintf(i18n.Tr("Moved to %s"), fs.DisplayName(toDir))) + // Just an informative messages + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +func (b *Bot) moveToChecklist(params []string) error { + toChecklistHash := params[0] + + msgHashes := strings.Split(params[1], ",") + + for _, msgHash := range msgHashes { + _, err := b.addToChecklist(toChecklistHash, msgHash) + if err != nil { + return fmt.Errorf("move to checklist: can't add to checklist: %w", err) + } + } + + return b.ShowHome(nil) +} + +func (b *Bot) addToChecklist(checklistHash string, msgHash string) (string, error) { + checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash) + // Create known checklist if it doesn't exist + if err != nil { + supportedChecklists := []string{ + fs.ChatFilename, + fs.LaterFilename, + fs.ReadFilename, + fs.WatchFilename, + fs.ShopFilename, + } + + created := false + for _, supportedChecklist := range supportedChecklists { + if fs.Hash(supportedChecklist) == checklistHash || supportedChecklist == checklistHash { + checklist = supportedChecklist + err = b.fs.Write(fs.DirUserRoot, checklist, "") + if err != nil { + return "", fmt.Errorf("add to checklist: can't create checklist %s: %w", checklist, err) + } + created = true + break + } + } + + if !created { + return "", fmt.Errorf("add to checklist: can't unhash checklist %s: %w", checklistHash, err) + } + } + + checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist) + if err != nil { + return "", fmt.Errorf("add to checklist: can't read checklist %s: %w", checklist, err) + } + + var item string + err = b.moveFromChat(func(content string, timestamp time.Time) error { + item = content + md := txt.AddChecklistItem(checklistMD, content, false) + return b.fs.Write(fs.DirUserRoot, checklist, md) + }, true, msgHash) + if err != nil { + return "", fmt.Errorf("move to checklist: can't move from chat: %w", err) + } + + return item, nil +} + +func (b *Bot) completeChecklistItem(params []string) error { + checklistHash := params[0] + itemHash := params[1] + + checklist, err := b.fs.Unhash(fs.DirUserRoot, checklistHash) + if err != nil { + return fmt.Errorf("complete checklist item: can't unhash checklist %s: %w", checklistHash, err) + } + + checklistMD, err := b.fs.Read(fs.DirUserRoot, checklist) + if err != nil { + return fmt.Errorf("complete checklist item: can't read checklist %s: %w", checklist, err) + } + + md, item := txt.CompleteChecklistItem(checklistMD, itemHash) + err = b.fs.Write(fs.DirUserRoot, checklist, md) + if err != nil { + return fmt.Errorf("complete checklist item: can't complete item from chat: %w", err) + } + + // We can tolerate failure of writing to journal, since that's not single source of truth. + // AddRecord prepends a fresh `HH:MM`; strip any leading timestamp on + // the item body so we don't end up with two of them. + _ = journal.AddRecord(b.fs, fmt.Sprintf("✅ %s", fs.DisplayName(txt.StripChatTimestamp(item))), b.cfg.Timezone()) + + if checklist == fs.LaterFilename { + return b.showLaterTasks(nil) + } else if checklist != fs.ChatFilename { + return b.showChecklist([]string{checklist}) + } + + return b.ShowHome(nil) +} + +func (b *Bot) requestNewDirName(params []string) error { + filenameHash := params[0] + + err := b.showHTML(i18n.Tr("OK. Send me the name for your new dir"), nil) + if err != nil { + return fmt.Errorf("request new dir: %w", err) + } + + b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewDir, []string{filenameHash, "%s"})) + + return nil +} + +// moveToNewDir accepts dir name as a second parameter +// which is a bit off, but the thing is sometimes it is replaced with +// inputExpectation, which only can add parameters in the end. +func (b *Bot) moveToNewDir(params []string) error { + msgIndicesStr := params[0] + dir := strings.ToLower(fs.SanitizeFilename(params[1])) + + exists, err := b.fs.Exists(fs.DirUserRoot, dir) + if err != nil { + return fmt.Errorf("move to new dir from caht: %w", err) + } + if !exists { + err = b.fs.MakeDir(dir) + if err != nil { + return fmt.Errorf("move to new dir from chat: %w", err) + } + } + + return b.moveToDir([]string{dir, msgIndicesStr}) +} + +// TODO reuse move to existing note as more general? +func (b *Bot) moveToExistingFile(params []string) error { + // TODO Remove input expectations if dir is not today (?) + existingFilenameHash := params[0] + + msgHashes := strings.Split(params[1], ",") + + existingFilename, err := b.fs.Unhash(fs.DirUserRoot, existingFilenameHash) + if err != nil { + return fmt.Errorf("move to file: can't unhash existing file '%s': %w", existingFilenameHash, err) + } + + err = b.moveFromChat(func(content string, timestamp time.Time) error { + return b.addToFile(fs.DirUserRoot, existingFilename, content) + }, true, msgHashes...) + if err != nil { + return fmt.Errorf("move to file: can't add to existing file '%s': %w", existingFilename, err) + } + + b.db.SetRecentCommand(CmdMoveToExistingFile) + b.db.SetRecentCommandParams([]string{fs.ShortHash(existingFilename)}) + + b.delAllKeyboards() + msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to %s"), fs.DisplayName(existingFilename))) + // Just an informative messages + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +func (b *Bot) moveToExistingNote(params []string) error { + toFilenameHash := params[0] + toDirHash := params[1] + + msgHashes := strings.Split(params[2], ",") + + var toDir string + if toDirHash == "" { + toDir = fs.DirUserRoot + } else { + var err error + toDir, err = b.fs.Unhash(fs.DirUserRoot, toDirHash) + if err != nil { + return fmt.Errorf("move to existing note: %w", err) + } + } + + toFilename, err := b.fs.Unhash(toDir, toFilenameHash) + if err != nil { + return fmt.Errorf("move to existing note:: %w", err) + } + + err = b.moveFromChat(func(content string, t time.Time) error { + err = b.addToFile(toDir, toFilename, content) + if err != nil { + return fmt.Errorf("move to existing note: can't add to file %s: %w", toFilename, err) + } + + b.db.SetRecentCommand(CmdMoveToExistingNote) + b.db.SetRecentCommandParams([]string{fs.ShortHash(toFilename), fs.ShortHash(toDir)}) + + return nil + }, false, msgHashes...) + if err != nil { + return fmt.Errorf("move to existing note: can't read content from chat: %w", err) + } + + b.delAllKeyboards() + msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to %s"), fs.DisplayName(toFilename))) + // Just an informative messages + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +func (b *Bot) moveToDirChecklist(params []string) error { + msgHashes := strings.Split(params[0], ",") + checklistDirHash := params[1] + + checklistDir, err := b.fs.Unhash(fs.DirUserRoot, checklistDirHash) + if err != nil { + return fmt.Errorf("move to checklistDir: %w", err) + } + + err = b.moveFromChat(func(content string, t time.Time) error { + isMultiline := txt.IsMultiline(content) + + if isMultiline && b.cfg.ShouldSplitChecklist(checklistDir) { + content = strings.TrimSpace(txt.NormNewLines(content)) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = fs.SanitizeFilename(line) + err = b.fs.Write(checklistDir, fs.Filename(line), "") + if err != nil { + return fmt.Errorf("move to checklistDir: %w", err) + } + } + } else { + sanitizedTitle, content, err := b.extractHeaderAndBody(content, maxHeaderLengthForMobile) + if err != nil { + return fmt.Errorf("move to checklistDir: %w", err) + } + filename := fs.Filename(sanitizedTitle) + return b.fs.Write(checklistDir, filename, content) + } + + return nil + }, false, msgHashes...) + if err != nil { + return fmt.Errorf("move to checklistDir: can't read content from chat: %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) moveToRead(params []string) error { + msgIndices := params[0] + + return b.moveToChecklist([]string{fs.Hash(fs.ReadFilename), msgIndices}) +} + +func (b *Bot) moveToWatch(params []string) error { + msgIndices := params[0] + + return b.moveToChecklist([]string{fs.Hash(fs.WatchFilename), msgIndices}) +} + +func (b *Bot) moveToShop(params []string) error { + msgIndices := params[0] + + return b.moveToChecklist([]string{fs.Hash(fs.ShopFilename), msgIndices}) +} + +func (b *Bot) moveToNewFile(params []string) error { + msgHash := params[0] + newFilenameFromUserInput := fs.Filename(params[1]) + + //filename, err := b.fs.Unhash(fs.DirUserRoot, msgIndex) + //if err != nil { + // return fmt.Errorf("move to new file: can't unhash existing file '%s': %w", msgIndex, err) + //} + // + //// Save existing filename to content in case the content of new file is empty (i.e. not multiline) + //content, err := b.fs.Read(fs.DirUserRoot, filename) + //if err != nil { + // return fmt.Errorf("move to new file: can't read file '%s': %w", filename, err) + //} + err := b.moveFromChat(func(content string, t time.Time) error { + content = strings.TrimSpace(content) + //if len(content) == 0 { + // content = fs.DisplayName(filename) + // err = b.fs.Write(fs.DirUserRoot, filename, content) + // if err != nil { + // return fmt.Errorf("move to new file: can't write content of '%s': %w", filename, err) + // } + //} + + // TODO check for safety + // TODO won't we lost some text here in case of multiline? + //err = b.fs.Rename(fs.DirUserRoot, filename, fs.DirUserRoot, newFilenameFromUserInput) + //if err != nil { + // return fmt.Errorf("move to new file: can't create empty file: %w", err) + //} + + // We can tolerate this + //_ = journal.AddRecord(b.fs, fmt.Sprintf("📄 %s", fs.DisplayName(filename)), b.cfg.Timezone()) + + b.db.SetRecentCommand(CmdMoveToExistingFile) + b.db.SetRecentCommandParams([]string{fs.ShortHash(newFilenameFromUserInput)}) + + // TODO add if exists + return b.fs.Write(fs.DirUserRoot, newFilenameFromUserInput, content) + }, false, msgHash) + if err != nil { + return fmt.Errorf("move to new file: can't read content from chat: %w", err) + } + + msg := txt.Emoji(i18n.Emoji("file"), fmt.Sprintf(i18n.Tr("Saved to %s"), fs.DisplayName(newFilenameFromUserInput))) + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +func (b *Bot) moveToNewChecklist(params []string) error { + msgHash := params[0] + + supposedName := params[1] + supposedName = fs.SanitizeFilename(supposedName) + + dir := strings.ToLower(supposedName) + dir = fmt.Sprintf("_%s_", dir) + exists, err := b.fs.Exists(fs.DirUserRoot, dir) + if err != nil { + return fmt.Errorf("move to new checklist: %w", err) + } + if !exists { + err = b.fs.MakeDir(dir) + } + + return b.moveToDir([]string{dir, msgHash}) +} + +func (b *Bot) moveToJournal(params []string) error { + msgHashes := params + + err := b.moveFromChat(func(content string, t time.Time) error { + // TODO take into account time from chat + return journal.AddRecord(b.fs, content, b.cfg.Timezone()) + }, false, msgHashes...) + if err != nil { + return fmt.Errorf("failed to move to journal: can't add record: %w", err) + } + + b.delAllKeyboards() + msg := txt.Emoji(i18n.Emoji("journal"), i18n.Tr("Saved to journal")) + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + if b.cfg.JournalOnlyMode() { + return nil + } + + return b.ShowHome(nil) +} + +func (b *Bot) addToJournalAndContinue(params []string) error { + content := params[0] + + err := journal.AddRecord(b.fs, content, b.cfg.Timezone()) + if err != nil { + return fmt.Errorf("failed to move to journal: can't add note: %w", err) + } + + // Don't return - continue to save to inbox as well. + msgHash, err := b.appendToChat(content, b.cfg.Timezone()) + if err != nil { + return fmt.Errorf("save to inbox: %w", err) + } + + return b.showMoveTo([]string{msgHash}) +} + +func (b *Bot) addToJournalFromShortcut(params []string) error { + content := params[0] + + // TODO change to pass text + err := journal.AddRecord(b.fs, content, b.cfg.Timezone()) + if err != nil { + return fmt.Errorf("failed to move to journal: can't add note: %w", err) + } + + msg := i18n.Tr("Saved to Journal") + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +// TODO add tests +func (b *Bot) addToRecentFileOrNoteFromShortcut(params []string) error { + content := params[0] + + args, _ := b.db.RecentCommandParams() + cmd, _ := b.db.RecentCommand() + + var existingFilename string + if cmd == CmdMoveToExistingFile { + var err error + existingFilename, err = b.fs.Unhash(fs.DirUserRoot, args[0]) + if err != nil { + return fmt.Errorf("failed to move to recent file or note: can't unhash filename: %w", err) + } + + err = b.addToFile(fs.DirUserRoot, existingFilename, content) + if err != nil { + return fmt.Errorf("failed to move to recent file: can't add note: %w", err) + } + } else if cmd == CmdMoveToExistingNote { + dir, err := b.fs.Unhash(fs.DirUserRoot, args[1]) + if err != nil { + return fmt.Errorf("failed to move to recent note: can't unhash dir: %w", err) + } + existingFilename, err = b.fs.Unhash(dir, args[0]) + if err != nil { + return fmt.Errorf("failed to move to recent note: can't unhash filename: %w", err) + } + + err = b.addToFile(dir, existingFilename, content) + if err != nil { + return fmt.Errorf("failed to move to recent note: can't add note: %w", err) + } + } else { + return nil + } + + msg := fmt.Sprintf(i18n.Tr("Added to %s"), fs.DisplayName(existingFilename)) + _, _ = b.tg.Send(b.userID, msg, nil, tg.MarkupHTML) + + return b.ShowHome(nil) +} + +func (b *Bot) moveToLater(params []string) error { + msgHash := params[0] + + return b.moveToChecklist([]string{fs.LaterFilename, msgHash}) +} + +// complete marks a single Chat.md entry as done ("- [ ]" => "- [x]"). +func (b *Bot) complete(params []string) error { + msgHash := params[0] + + key, err := b.fs.SafePath(fs.DirUserRoot, "") + if err != nil { + return fmt.Errorf("complete: %w", err) + } + lock := userLock(key) + lock.Lock() + defer lock.Unlock() + + content, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return fmt.Errorf("complete: can't read inbox: %w", err) + } + + newContent, item := txt.CompleteChecklistItem(content, msgHash) + if item == "" { + return b.ShowHome(nil) + } + + if err := b.fs.Write(fs.DirUserRoot, fs.ChatFilename, newContent); err != nil { + return fmt.Errorf("complete: can't write inbox: %w", err) + } + + if item == fs.PomodoroTask { + err = b.cfg.AddToSchedule(fs.PomodoroTask, time.Now().Unix()+int64(b.cfg.PomodoroDuration().Seconds()), "") + if err != nil { + return fmt.Errorf("complete: can't add pomodoro to schedule: %w", err) + } + } + + return b.ShowHome(nil) +} + +func (b *Bot) completeListItem(params []string) error { + dirHash := params[0] + filenameHash := params[1] + + dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash) + if err != nil { + return fmt.Errorf("complete: can't unhash dir %s: %w", dir, err) + } + + filename, err := b.fs.Unhash(dir, filenameHash) + if err != nil { + return fmt.Errorf("complete: can't unhash filename %s: %w", filename, err) + } + + if err = b.fs.Touch(dir, filename); err != nil { + return fmt.Errorf("complete: can't touch %s: %w", filename, err) + } + + err = b.fs.Rename(dir, filename, fs.DirArchive, filename) + if err != nil { + return fmt.Errorf("complete: can't complete %s: %w", filename, err) + } + + return b.showChecklist([]string{dirHash}) +} + +func (b *Bot) showChecklistItem(params []string) error { + dirHash := params[0] + filenameHash := params[1] + + dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash) + if err != nil { + return fmt.Errorf("show checklist item: can't unhash dir %s: %w", dir, err) + } + + filename, err := b.fs.Unhash(dir, filenameHash) + if err != nil { + return fmt.Errorf("show checklist item: can't unhash filename %s: %w", filename, err) + } + + content, err := b.fs.Read(dir, filename) + if err != nil { + return fmt.Errorf("show checklist item: can't read content of %s: %w", filename, err) + } + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn(i18n.StrBack, tg.NewCmd(CmdShowChecklist, []string{dirHash})), + tg.NewBtn(i18n.StrComplete, tg.NewCmd(CmdCompleteListItem, []string{dirHash, filenameHash})), + ), + }) + + err = b.showHTML(content, kb) + if err != nil { + return fmt.Errorf("show checklist item: %w", err) + } + + msgID, hasLastKeyboard := b.db.LastKeyboardMsgID() + if hasLastKeyboard { + b.db.SetHashOrPathByMsgID(msgID, filepath.ToSlash(filepath.Join(dir, filename))) + } + + return nil +} + +func (b *Bot) schedule(params []string) error { + msgHash := params[0] + timeStr := params[1] + cron := params[2] + + scheduleTime, err := strconv.ParseInt(timeStr, 10, 64) + if err != nil { + return fmt.Errorf("schedule: can't parse timestamp: %w", err) + } + + item, err := b.addToChecklist(fs.LaterFilename, msgHash) + if err != nil { + return fmt.Errorf("schedule: can't move to later: %w", err) + } + + err = b.cfg.AddToSchedule(item, scheduleTime, cron) + if err != nil { + return fmt.Errorf("schedule: can't add to schedule: %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) scheduleForTmrw(params []string) error { + return b.schedule([]string{params[0], txt.I64(Tomorrow()), ""}) +} + +func (b *Bot) delAllKeyboards() { + var msgIDs []int + mid, hasLastKeyboard := b.db.LastKeyboardMsgID() + if hasLastKeyboard { + b.db.DelLastKeyboardMsgID() + msgIDs = append(msgIDs, mid) + } + + // No worries if we fail - it will be cleaned up by the worker + for _, msgID := range msgIDs { + // If we fail to del - user would get a bunch + // of keyboards in one chat, which is messy but not critical + _ = b.tg.Del(b.userID, msgID) + } +} + +func (b *Bot) delAllImages() { + mids, hasSentImages := b.db.ImgMsgID() + if !hasSentImages { + return + } + + b.db.DelImgMsgID() + for _, mid := range mids { + // If we fail to del - user would get a bunch + // of keyboards in one chat, which is messy but not critical + _ = b.tg.Del(b.userID, mid) + } +} + +func (b *Bot) showToADay(params []string) error { + filenameHash := params[0] + + kb, err := b.toADayKeyboard(filenameHash) + if err != nil { + return fmt.Errorf("show for a day: %w", err) + } + + err = b.showHTML(i18n.Tr("Choose a day"), kb) + if err != nil { + return fmt.Errorf("show for a day: %w", err) + } + + return nil +} + +func (b *Bot) toADayKeyboard(filenameHash string) (*tg.Keyboard, error) { + newBtn := func(name, cron string) tg.Btn { + return tg.NewBtn(name, tg.NewCmd(CmdSchedule, []string{filenameHash, txt.I64(NextExcludeToday(cron)), ""})) + } + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow(tg.NewBtn(i18n.StrRepeat, tg.NewCmd(CmdShowScheduleForDayRecurring, []string{filenameHash}))), + tg.NewRow( + newBtn(i18n.StrMonday, "0 0 * * 1"), + newBtn(i18n.StrTuesday, "0 0 * * 2"), + newBtn(i18n.StrWednesday, "0 0 * * 3"), + newBtn(i18n.StrThursday, "0 0 * * 4"), + ), + tg.NewRow( + newBtn(i18n.StrFriday, "0 0 * * 5"), + newBtn(i18n.StrSaturday, "0 0 * * 6"), + newBtn(i18n.StrSunday, "0 0 * * 0"), + ), + }) + + for _, iAndj := range [][]int{{1, 8}, {9, 16}, {17, 24}, {25, 31}} { + row := tg.NewRow() + for i := iAndj[0]; i <= iAndj[1]; i++ { + cron := fmt.Sprintf("0 0 %d * *", i) + row = append(row, newBtn(txt.I64(int64(i)), cron)) + } + kb.AddRow(row) + } + kb.AddRow(tg.NewBtn(i18n.StrToToday, tg.NewCmd(CmdShowHome, nil))) + + return kb, nil +} + +func (b *Bot) showMoveToFileOrDir(params []string) error { + msgHash := params[0] + maxRecentBtns := maxGroupedBtnsInMoveTo + + userWantedAllBtns := len(params) > 1 + if userWantedAllBtns { + maxRecentBtns = maxBtns + } else { + //b.db.SetRecentCommand(CmdMoveToExistingFile) + //b.db.SetRecentCommandParams([]string{fs.ShortHash(filename), fs.ShortHash(fs.DirToday)}) + } + + kb := tg.NewKeyboard(nil) + skippedBtns := false + + //fileBtns, err := b.moveToFileBtns(fs.ShortHash(filename)) + fileBtns, err := b.moveToFileBtns(msgHash) + if err != nil { + return fmt.Errorf("to file dialog: %w", err) + } + if len(fileBtns) > maxRecentBtns { + fileBtns = fileBtns[:maxRecentBtns] + skippedBtns = true + } + // Move newly created file to the end of the files list + if len(fileBtns) > 0 { + fileBtns = append(fileBtns[1:], fileBtns[0]) + } + fileBtnsByRows := slice.Chunk(fileBtns, btnsPerRow) + for _, row := range fileBtnsByRows { + kb.AddRow(row) + } + + dirBtns, err := b.moveToDirBtns(msgHash) + if err != nil { + return fmt.Errorf("to file dialog: %w", err) + } + if len(dirBtns) > maxRecentBtns { + dirBtns = dirBtns[:maxRecentBtns] + skippedBtns = true + } + // Add "New Dir" to the end of the dirs list + // TODO add tests for all these cases + if len(dirBtns) == maxRecentBtns { + // Free up space for the new dir button + dirBtns = dirBtns[:len(fileBtns)-1] + } + btn := tg.NewBtn("🗂 New Dir", tg.NewCmd(CmdRequestNewDir, []string{msgHash})) + dirBtns = append(dirBtns, btn) + + //shouldAddSeparator := len(fileBtns) > 0 + //if shouldAddSeparator { + searchCMD := tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat) + kb.AddRow(tg.NewBtn(i18n.Tr("Search"), searchCMD)) + //} + dirBtnsByRows := slice.Chunk(dirBtns, btnsPerRow) + for _, row := range dirBtnsByRows { + kb.AddRow(row) + } + + if skippedBtns { + kb.AddRow(tg.NewBtn(i18n.Tr("More..."), tg.NewCmd(CmdShowMoveToDirOrFile, []string{msgHash, "full"}))) + } + + b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewFile, []string{msgHash, "%s"})) + + err = b.showHTML("📄 Select where to save or send a new name:", kb) + if err != nil { + return fmt.Errorf("to file dialog: %w", err) + } + + return nil +} + +func (b *Bot) showToChecklist(params []string) error { + filenameHash := params[0] + + kb, err := b.toChecklistKeyboard(filenameHash) + if err != nil { + return fmt.Errorf("show to checklist: can't get keyboard: %w", err) + } + + b.db.SetInputExpectation(tg.NewCmd(CmdMoveToNewChecklist, []string{filenameHash, "%s"})) + + err = b.showHTML(i18n.Tr("Choose a checklist or name a new one"), kb) + if err != nil { + return fmt.Errorf("show to checklist: %w", err) + } + + return nil +} + +func (b *Bot) moveToFileBtns(msgHash string) ([]tg.Btn, error) { + files, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return nil, fmt.Errorf("to doc keyboard: %w", err) + } + files = fs.OnlyUserMDFiles(files) + files = fs.SortByCtimeDesc(files) + if len(files) == 0 { + return nil, nil + } + + var buttons []tg.Btn + newBtn := func(title, existingFilenameHash string) tg.Btn { + title = fmt.Sprintf("%s", title) + params := []string{existingFilenameHash, msgHash} + return tg.NewBtn(title, tg.NewCmd(CmdMoveToExistingFile, params)) + } + for _, file := range files { + buttons = append(buttons, newBtn(file.DisplayName, fs.ShortHash(file.Name))) + } + + return buttons, nil +} + +func (b *Bot) moveToDirBtns(msgHash string) ([]tg.Btn, error) { + newBtn := func(dir string) tg.Btn { + emojifiedDir := fmt.Sprintf("%s %s", i18n.Emoji("dir"), txt.Ucfirst(dir)) + return tg.NewBtn(emojifiedDir, tg.NewCmd(CmdMoveToExistingDir, []string{fs.ShortHash(dir), msgHash})) + } + + dirs, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return nil, fmt.Errorf("To File keyboard: %w", err) + } + dirs = fs.OnlyNoteDirs(fs.OnlyDirs(dirs)) + dirs = fs.SortByCtimeDesc(dirs) + + var buttons []tg.Btn + for _, dir := range dirs { + buttons = append(buttons, newBtn(dir.Name)) + } + + return buttons, nil +} + +func (b *Bot) toChecklistKeyboard(filenameHash string) (*tg.Keyboard, error) { + newBtn := func(dir, title string) tg.Btn { + return tg.NewBtn(title, tg.NewCmd(CmdMoveToDirChecklist, []string{filenameHash, dir})) + } + + dirs, err := b.fs.FilesAndDirs(fs.DirUserRoot) + if err != nil { + return nil, fmt.Errorf("to checklist keyboard: %w", err) + } + // TODO handle case with zero dirs (inline_keyboard is null), for all similar cases + dirs = fs.OnlyChecklists(fs.OnlyDirs(dirs)) + + kb := tg.NewKeyboard(nil) + for _, dir := range dirs { + kb.AddRow(newBtn(dir.Name, dir.DisplayName)) + } + + return kb, nil +} + +func (b *Bot) togglePomodoro(_ []string) error { + // Check if Pomodoro is already running + hasPomodoroInToday := false + todayMD, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err == nil { + _, isCompleted := txt.ChecklistItems(todayMD) + _, hasPomodoroInToday = isCompleted[fs.PomodoroTask] + } + + if hasPomodoroInToday { + todayMD, _ = txt.RemoveChecklistItem(todayMD, fs.PomodoroTask) + err = b.fs.Write(fs.DirUserRoot, fs.ChatFilename, todayMD) + if err != nil { + return fmt.Errorf("toggle pomodoro: failed to delete pomodoro file: %w", err) + } + } + + if hasPomodoroInToday { + _, _ = b.tg.Send(b.userID, "Pomodoro is stopped", nil, tg.MarkupHTML) + return b.ShowHome(nil) + } + + // Create Pomodoro checklist item + err = b.fs.Write(fs.DirUserRoot, fs.ChatFilename, txt.AddChecklistItem(todayMD, fs.PomodoroTask, false)) + + _, err = b.tg.Send(b.userID, i18n.PomodoroStarted, nil, tg.MarkupHTML) + if err != nil { + return fmt.Errorf("toggle pomodoro: failed to show pomodoro hint message %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) showToADayRecurring(params []string) error { + filenameHash := params[0] + + newBtn := func(name, cron string) tg.Btn { + // We need to shorten filehash, otherwise whole payload doesn't fit telegram's restrictions (64 bytes) + cmd := tg.NewCmd(CmdSchedule, []string{txt.Substr(filenameHash, 0, 4), txt.I64(NextExcludeToday(cron)), cron}) + return tg.NewBtn(name, cmd) + } + + kb := tg.NewKeyboard([]tg.Row{ + // Cron format: Minute Hour DayOfMonth Month DayOfWeek + tg.NewRow( + newBtn(i18n.StrWeekdays, "0 0 * * 1-5"), + newBtn(i18n.StrEveryday, "0 0 * * *"), + ), + tg.NewRow( + newBtn(i18n.StrMonday, "0 0 * * 1"), + newBtn(i18n.StrTuesday, "0 0 * * 2"), + newBtn(i18n.StrWednesday, "0 0 * * 3"), + newBtn(i18n.StrThursday, "0 0 * * 4"), + ), + tg.NewRow( + newBtn(i18n.StrFriday, "0 0 * * 5"), + newBtn(i18n.StrSaturday, "0 0 * * 6"), + newBtn(i18n.StrSunday, "0 0 * * 0"), + ), + }) + + for week := 0; week < 4; week++ { + row := tg.NewRow() + for day := 1; day < 8; day++ { + i := week*7 + day + cron := fmt.Sprintf("0 0 %d * *", i) + row = append(row, newBtn(txt.I64(int64(i)), cron)) + } + kb.AddRow(row) + } + kb.AddRow(tg.NewBtn(i18n.StrToToday, tg.NewCmd(CmdShowHome, nil))) + + err := b.showHTML(i18n.Tr("Repeat the task"), kb) + if err != nil { + return fmt.Errorf("showRecuringKeyboard : %w", err) + } + + return nil +} + +// addToFile adds content at the top of the file. +// Creates a file if not exists. +func (b *Bot) addToFile(dir, filename, content string) error { + existingContent, err := b.fs.Read(dir, filename) + // Ignore if file is missing, it would be created. + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("add to file: can't read existing file: %w", err) + } + + header := fmt.Sprintf("#### %d %s %d, %s", now().Day(), now().Format("January"), now().Year(), now().Weekday()) + newContent := txt.AddHeaderAndText(existingContent, header, content) + + err = b.fs.Write(dir, filename, newContent) + if err != nil { + return fmt.Errorf("add to file: can't save file: %w", err) + } + + return nil +} + +func (b *Bot) openInApp(_ []string) error { + token := sync.GenOneTimeToken(b.userID) + onetimeURL := fmt.Sprintf("%s?token=%s", config.ServerCfg.AppURL, token) + kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.Tr("Open in app"), tg.NewURLCmd(onetimeURL))}) + + return b.showHTML(i18n.Tr("🔗 Here's your one-time link! Desktop-only for now."), kb) +} + +func (b *Bot) showHelp(_ []string) error { + kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))}) + + return b.showHTML("Refer to files.md for help!", kb) +} + +func (b *Bot) download(_ []string) error { + kb := tg.NewKeyboard([]tg.Row{tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))}) + + return b.showHTML("Not yet implemented 🏗!", kb) +} + +func (b *Bot) setTasksOnlyMode(_ []string) error { + err := b.cfg.SetMode(userconfig.ModeTasks) + if err != nil { + return fmt.Errorf("tasks only mode: can't set notes only mode %w", err) + } + + cmds, err := b.cfg.MoveToCmds() + if err != nil { + return fmt.Errorf("tasks only mode: can't get quick commands %w", err) + } + + for _, cmd := range cmds { + err = b.cfg.DelMoveToCmd(cmd) + if err != nil { + return fmt.Errorf("tasks only mode: can't delete quick command %w", err) + } + } + + moveToCmds := []string{ + CmdScheduleForTmrw, + CmdMoveToLater, + CmdShowScheduleForDay, + } + for _, cmd := range moveToCmds { + err = b.cfg.AddMoveToCmd(cmd) + if err != nil { + return fmt.Errorf("full mode: can't add quick command %w", err) + } + } + + return b.ShowHome(nil) +} + +func (b *Bot) setNotesOnlyMode(_ []string) error { + err := b.cfg.SetMode(userconfig.ModeNotes) + if err != nil { + return fmt.Errorf("notes only mode: can't set notes only mode %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) setJournalOnlyMode(_ []string) error { + err := b.cfg.SetMode(userconfig.ModeJournal) + if err != nil { + return fmt.Errorf("journal only mode: can't set notes only mode %w", err) + } + + return b.showHTML(i18n.Tr("What's on your mind?"), nil) +} + +func (b *Bot) setFullMode(_ []string) error { + err := b.cfg.SetMode(userconfig.ModeFull) + if err != nil { + return fmt.Errorf("full mode: can't set notes only mode %w", err) + } + + moveToCmds := []string{ + CmdShowMoveToDirOrFile, + CmdMoveToRead, + CmdMoveToShop, + CmdMoveToWatch, + CmdMoveToJournal, + } + for _, cmd := range moveToCmds { + err = b.cfg.AddMoveToCmd(cmd) + if err != nil { + return fmt.Errorf("full mode: can't add quick command %w", err) + } + } + + err = b.fs.CreateSystemDirs() + if err != nil { + return fmt.Errorf("full mode: can't create dirs: %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) setChatOnlyMode(_ []string) error { + err := b.cfg.SetMode(userconfig.ModeChat) + if err != nil { + return fmt.Errorf("chat only mode: can't set chat only mode %w", err) + } + + return b.showHTML(i18n.Tr("What's on your mind?"), nil) +} + +func (b *Bot) completeHabit(params []string) error { + habit := params[0] + userHabits, err := habits.Habits(b.fs, time.Now().Year()) + if err != nil { + return fmt.Errorf("complete habit: can't get habits: %w", err) + } + + userHabits[habit][time.Now().YearDay()] = 1 + + err = habits.Write(b.fs, time.Now().Year(), userHabits) + if err != nil { + return fmt.Errorf("complete habit: can't write habits: %w", err) + } + + emoji := habits.Emoji(b.fs, habit) + + userConf := userconfig.NewConfig(b.fs, b.userID, config.ServerCfg.ConfigFilename) + err = journal.AddEmoji(b.fs, emoji, userConf.Timezone()) + if err != nil { + return fmt.Errorf("complete habit: can't write emoji to journal: %w", err) + } + + record := fmt.Sprintf("%s %s", emoji, habit) + err = journal.AddRecord(b.fs, record, userConf.Timezone()) + if err != nil { + return fmt.Errorf("complete habit: can't write record to journal: %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) shareNote(params []string) error { + dirHash := params[0] + filenameHash := params[1] + + dir, err := b.fs.Unhash(fs.DirUserRoot, dirHash) + if err != nil { + return fmt.Errorf("share note: can't find dir: %w", err) + } + + filename, err := b.fs.Unhash(dir, filenameHash) + if err != nil { + return fmt.Errorf("share note: can't find file: %w", err) + } + + content, err := b.fs.Read(dir, filename) + if err != nil { + return fmt.Errorf("share note: %w", err) + } + + for _, channel := range b.cfg.Channels() { + probablyInvalidMD := fmt.Sprintf("**%s/%s**\n\n%s", fs.DisplayName(dir), fs.DisplayName(filename), content) + probablyInvalidMD, images, _ := txt.ExtractTextImgsLinks(probablyInvalidMD) + // Sending a gallery of images if there are any + if len(images) > 0 { + // We tolerate errors with the image gallery for now, text is more important + mids, imgErr := b.tg.SendImages(channel, images) + if imgErr == nil { + for _, imgMid := range mids { + b.db.AddImgMsgID(imgMid) + } + } else { + slog.Error("Can't send images", "error", imgErr) + } + } + + // If our msg is too long, we send maxMsgsToSendAtOnce first messages. + // Keyboard is attached to the last one + textChunks := txt.SplitTextIntoChunks(probablyInvalidMD, maxMsgLength) + textChunks = textChunks[0:min(maxMsgsToSendAtOnce, len(textChunks))] + lastChunk := textChunks[len(textChunks)-1] + textChunks = textChunks[0 : len(textChunks)-1] + for _, textChunk := range textChunks { + _, _ = b.tg.Send(b.userID, txt.MarkdownToHTML(textChunk), nil, tg.MarkupHTML) + } + + _, err := b.tg.Send(channel, txt.MarkdownToHTML(lastChunk), nil, tg.MarkupHTML) + if err != nil { + return fmt.Errorf("share: %w", err) + } + } + + return nil +} + +func extractMarkdown(u Update) string { + content := txt.TelegramEntitiesToMarkdown(u.MsgText(), u.MsgEntities()) + content = strings.TrimSpace(txt.NormNewLines(content)) + + return txt.Ucfirst(content) +} + +func checklistTitle(checklist string) string { + checklist = strings.TrimSuffix(checklist, filepath.Ext(checklist)) + stripChecklistChars := regexp.MustCompile(`^_.*?_(.+)`) + title := stripChecklistChars.ReplaceAllString(checklist, "$1") + title = strings.TrimPrefix(strings.TrimSuffix(title, "_"), "_") + + return fs.DisplayName(title) +} + +func completedMsg() string { + msgs := []string{ + "Completed! 🚀", + "Done! 🎉", + "Awesome! 💪", + "Great job! 🌟", + "Good work! 🎈", + "Nice! 🎊", + "Fantastic! 🎇", + "Excellent! 🎯", + "Perfect! 🏆", + "Bravo! 👏", + "Superb! 🌠", + "You did it! ✅", + "Nicely done! 🎖", + "Nailed it! 🎯", + } + + return msgs[rand.Intn(len(msgs))] +} diff --git a/server/bot_forwards.go b/server/bot_forwards.go new file mode 100644 index 0000000..9cb37a0 --- /dev/null +++ b/server/bot_forwards.go @@ -0,0 +1,84 @@ +// Don't want to put this hacky complex code into main bot file. +// The purpose of this file is to collapse a few consecutive incoming messages +// into one file. This is useful when a user forwards a few messages to the bot. +// +// 1) For a single message the flow is the same, no changes are made. +// 2) For any additional messages, we check the time of the last message. +// 3) If it was less than or equal to 1 second ago, we collapse it with the first message in the batch. +// 4) A batch is a sequence of messages with a distance of no more than 1 second between them. +// 5) That’s it. +// +// Suppose we have the following timestamps for incoming messages: 0 0 1 1 1 2 2 2 2 3. +// This is all one batch (distance of no more than 1 second between them). +// So we collapse all messages into the first message of the batch (time=0). +// +// Physically, a user cannot send so many messages manually, so we hypothesize +// that these messages were forwarded. I don’t particularly like this assumption, +// but that's the ugliness of real-world problems reflected into code. +// When a user forwards a few messages from "Saved Messages" dialog +// the messages don't have "is_forwarded" flag, so we can only +// distinguish them by using this time-based heuristic. + +package server + +import ( + "sync" +) + +var ( + firstMsgHashes sync.Map + firstMsgTimes sync.Map +) + +func firstMsgTime(userID int64) (int, bool) { + time, ok := firstMsgTimes.Load(userID) + if !ok { + return 0, false + } + + return time.(int), true +} + +func setFirstMsgTime(userID int64, time int) { + firstMsgTimes.Store(userID, time) +} + +func firstMsgHash(userID int64) (string, bool) { + msg, ok := firstMsgHashes.Load(userID) + if !ok { + return "", false + } + + return msg.(string), true +} + +// setFirstMsgHash records the hash of the first entry in a forward batch. +// If a batch is already active (within 1-second distance) this is a no-op — +// only the first message's identity is tracked. +func setFirstMsgHash(userID int64, msgHash string, time int) { + firstTime, ok := firstMsgTime(userID) + if ok { + diff := time - firstTime + // Sent in exactly same second or second after + if diff == 0 || diff == 1 { + return + } + } + + firstMsgHashes.Store(userID, msgHash) +} + +func collapseToMsg(userID int64, time int) (string, bool) { + firstTime, ok := firstMsgTime(userID) + if !ok { + return "", false + } + + diff := time - firstTime + // Sent in exactly same second or second after + if diff == 0 || diff == 1 { + return firstMsgHash(userID) + } + + return "", false +} diff --git a/server/bot_settings.go b/server/bot_settings.go new file mode 100644 index 0000000..4c8ab94 --- /dev/null +++ b/server/bot_settings.go @@ -0,0 +1,338 @@ +package server + +import ( + "fmt" + + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/i18n" + "github.com/zakirullin/files.md/server/pkg/tg" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +const ( + addBtn = "➕" + delBtn = "➖" +) + +var AvailableMoveToBtns = []tg.Btn{ + tg.NewBtn(i18n.StrToTomorrow, tg.NewCmd(CmdScheduleForTmrw, nil)), + tg.NewBtn(i18n.StrToLater, tg.NewCmd(CmdMoveToLater, nil)), + tg.NewBtn(i18n.StrToADay, tg.NewCmd(CmdShowScheduleForDay, nil)), + tg.NewBtn(i18n.StrToFile, tg.NewCmd(CmdShowMoveToDirOrFile, nil)), + tg.NewBtn(i18n.StrToJournal, tg.NewCmd(CmdMoveToJournal, nil)), + tg.NewBtn(i18n.StrToRead, tg.NewCmd(CmdMoveToRead, nil)), + tg.NewBtn(i18n.StrToWatch, tg.NewCmd(CmdMoveToWatch, nil)), + tg.NewBtn(i18n.StrToShop, tg.NewCmd(CmdMoveToShop, nil)), + tg.NewBtn(i18n.StrToChecklist, tg.NewCmd(CmdShowMoveToChecklist, nil)), +} + +var AvailableQuickBtns = []tg.Btn{ + tg.NewBtn("Later", tg.NewCmd(CmdLater, nil)), + tg.NewBtn("Search", tg.NewCustomCmd(CmdInlineQuerySearchEveryWhere, nil, tg.CmdTypeInlineQueryCurrentChat)), + tg.NewBtn("Files", tg.NewCmd(CmdShowFiles, nil)), + tg.NewBtn("Checklists", tg.NewCmd(CmdShowChecklists, nil)), + tg.NewBtn("Postpone", tg.NewCmd(CmdShowPostpone, nil)), + tg.NewBtn("Read", tg.NewCmd(CmdShowReadChecklist, nil)), + tg.NewBtn("Watch", tg.NewCmd(CmdShowWatchChecklist, nil)), + tg.NewBtn("Shop", tg.NewCmd(CmdShowShopChecklist, nil)), + tg.NewBtn("Schedule", tg.NewCmd(CmdShowSchedule, nil)), + tg.NewBtn("Habits", tg.NewCustomCmd(CmdWebAppHabits, nil, tg.CmdTypeWebApp)), + tg.NewBtn("Random", tg.NewCmd(CmdRandomNote, nil)), +} + +func (b *Bot) showSettings(_ []string) error { + var kb tg.Keyboard + kb.AddRow(tg.NewBtn(txt.Emoji(i18n.Emoji("brain"), b.tr("Full mode")), tg.NewCmd(CmdFullMode, nil))) + kb.AddRow(tg.NewBtn(txt.Emoji(i18n.Emoji("notes"), b.tr("Notes mode")), tg.NewCmd(CmdNotesOnlyMode, nil))) + kb.AddRow(tg.NewBtn(txt.Emoji(i18n.Emoji("tasks"), b.tr("Tasks mode")), tg.NewCmd(CmdTasksOnlyMode, nil))) + //kb.AddRow(tg.NewBtn(txt.Emoji(i18n.Emoji("journal"), b.tr("Journal mode")), tg.NewCmd(CmdJournalOnlyMode, nil))) + kb.AddRow(tg.NewBtn("-", tg.NewCmd(CmdDoNothing, nil))) + kb.AddRow(tg.NewBtn(i18n.StrQuickBtns, tg.NewCmd(CmdShowQuickBtnsSettings, nil))) + kb.AddRow(tg.NewBtn(i18n.StrMoveToBtns, tg.NewCmd(CmdShowMoveToBtnsSettings, nil))) + kb.AddRow(tg.NewBtn(txt.Emoji(i18n.Emoji("world"), b.tr("Timezone")), tg.NewCmd(CmdShowTimezone, nil))) + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + err := b.showHTML("Settings:", &kb) + if err != nil { + return fmt.Errorf("showSettings : %w", err) + } + + return nil +} + +func (b *Bot) showTimezone(_ []string) error { + var kb tg.Keyboard + timezones := []string{ + "UTC", + + // Europe + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Istanbul", + "Europe/London", + "Europe/Madrid", + "Europe/Moscow", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Warsaw", + + // Asia + "Asia/Nicosia", + "Asia/Tbilisi", + "Asia/Tokyo", + + // Africa + "Africa/Cairo", + "Africa/Johannesburg", + + // Americas + "America/Buenos_Aires", + "America/Lima", + "America/Los_Angeles", + "America/New_York", + "America/Santiago", + } + + for _, tz := range timezones { + name := tz + if b.cfg.Timezone().String() == tz { + name = "✅ " + name + } + kb.AddRow(tg.NewBtn(name, tg.NewCmd(CmdSetTimezone, []string{tz}))) + } + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + err := b.showHTML("Timezone:", &kb) + if err != nil { + return fmt.Errorf("showTimezone : %w", err) + } + + return nil +} + +func (b *Bot) setTimezone(params []string) error { + timezone := params[0] + + err := b.cfg.SetTimezone(timezone) + if err != nil { + return fmt.Errorf("setTimezone : %w", err) + } + + return b.ShowHome(nil) +} + +func (b *Bot) showQuickBtnsSettings(params []string) error { + var kb tg.Keyboard + + // Step 1. Append all buttons that are already chosen by user + var usedCmds []string + + // We iterate through hardcoded panel to preserve order of buttons in UI + cmds, err := b.cfg.QuickCmds() + if err != nil { + return fmt.Errorf("can't get quick cmds: %w", err) + } + + for _, cmd := range cmds { + for _, btn := range AvailableQuickBtns { + if btn.Cmd.Name != cmd { + continue + } + + name := fmt.Sprintf("%s %s %s", i18n.Emoji(btn.Name), btn.Name, delBtn) + enabledCmd := tg.NewCmd(CmdDelFromQuickBtns, []string{btn.Cmd.Name}) + kb.AddRow(tg.NewBtn(name, enabledCmd)) + usedCmds = append(usedCmds, cmd) + break + } + } + + kb.AddRow(tg.NewBtn("-", tg.NewCmd(CmdDoNothing, nil))) + + // Step 2. now, let's fill buttons that are not disabled... + for _, btn := range AvailableQuickBtns { + // Check if command is enabled + cmdUsed := false + for _, usedCmd := range usedCmds { + if btn.Cmd.Name == usedCmd { + cmdUsed = true + } + } + if cmdUsed { + continue + } + // Command is not enabled, so add it to disabled list + name := fmt.Sprintf("%s %s %s", i18n.Emoji(btn.Name), btn.Name, addBtn) + disabledCmd := tg.NewCmd(CmdAddToQuickBtns, []string{btn.Cmd.Name}) + kb.AddRow(tg.NewBtn(name, disabledCmd)) + } + + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + text := fmt.Sprintf("Configure quick buttons (%s = add to quick buttons, %s = to remove from quick buttons):", addBtn, delBtn) + err = b.showHTML(text, &kb) + if err != nil { + return fmt.Errorf("configureQuickPanel : %w", err) + } + + return nil +} + +func (b *Bot) addToQuickBtns(params []string) error { + cmd := params[0] + + // Search whether a command is valid + found := false + for _, btn := range AvailableQuickBtns { + if btn.Cmd.Name == cmd { + found = true + break + } + } + + if !found { + return fmt.Errorf("unknown command: %s", cmd) + } + + if cmd == "Habits" { + b.fs.CreateDirsIfNotExist(fs.DirHabits, fs.DirInsights) + } + + err := b.cfg.AddQuickCmd(cmd) + if err != nil { + return fmt.Errorf("can't add to quick buttons: %w", err) + } + + return b.showQuickBtnsSettings([]string{}) +} + +func (b *Bot) delFromQuickBtns(params []string) error { + cmd := params[0] + + _ = b.cfg.DelQuickCmd(cmd) + + return b.showQuickBtnsSettings([]string{}) +} + +func (b *Bot) quickBtns() []tg.Btn { + quickBtnsRow := tg.NewRow() + // We can tolerate missing quick btns + cmds, _ := b.cfg.QuickCmds() + for _, cmd := range cmds { + for _, btn := range AvailableQuickBtns { + if btn.Cmd.Name == cmd { + if btn.Cmd.Name == CmdWebAppHabits { + habitsUrl := fmt.Sprintf("%s/habits_v2/%d", config.ServerCfg.APIURL, b.userID) + btn.Cmd.Params = []string{habitsUrl} + } + btn.Name = i18n.Emoji(btn.Name) + + quickBtnsRow = append(quickBtnsRow, btn) + break + } + } + } + + return quickBtnsRow +} + +// A little copy-paste from showQuickBtnsSettings +func (b *Bot) showMoveToBtnsSettings(params []string) error { + var kb tg.Keyboard + + // Step 1. Append all buttons that are already chosen by user + var usedCmds []string + + // We iterate through hardcoded panel to preserve order of buttons in UI + cmds, err := b.cfg.MoveToCmds() + if err != nil { + return fmt.Errorf("can't get move to cmds: %w", err) + } + for _, cmd := range cmds { + for _, btn := range AvailableMoveToBtns { + if btn.Cmd.Name != cmd { + continue + } + + name := txt.Emoji(delBtn, btn.Name) + enabledCmd := tg.NewCmd(CmdDelFromMoveToBtns, []string{btn.Cmd.Name}) + kb.AddRow(tg.NewBtn(name, enabledCmd)) + usedCmds = append(usedCmds, cmd) + break + } + } + + kb.AddRow(tg.NewBtn("-", tg.NewCmd(CmdDoNothing, nil))) + + // Step 2. now, let's fill buttons that are not disabled... + for _, btn := range AvailableMoveToBtns { + // Check if command is enabled + cmdUsed := false + for _, usedCmd := range usedCmds { + if btn.Cmd.Name == usedCmd { + cmdUsed = true + } + } + if cmdUsed { + continue + } + // Command is not enabled, so add it to disabled list + name := txt.Emoji(addBtn, btn.Name) + disabledCmd := tg.NewCmd(CmdAddToMoveToBtns, []string{btn.Cmd.Name}) + kb.AddRow(tg.NewBtn(name, disabledCmd)) + } + + kb.AddRow(tg.NewBtn(i18n.StrHome, tg.NewCmd(CmdShowHome, nil))) + + text := fmt.Sprintf("Configure quick panel (%s = add to panel, %s = to remove):", addBtn, delBtn) + err = b.showHTML(text, &kb) + if err != nil { + return fmt.Errorf("configureQuickPanel : %w", err) + } + + return nil +} + +func (b *Bot) addToMoveToBtns(params []string) error { + cmd := params[0] + + err := b.cfg.AddMoveToCmd(cmd) + if err != nil { + return fmt.Errorf("can't add to move to buttons: %w", err) + } + + return b.showMoveToBtnsSettings([]string{}) +} + +func (b *Bot) delFromMoveToBtns(params []string) error { + cmd := params[0] + + err := b.cfg.DelMoveToCmd(cmd) + if err != nil { + return fmt.Errorf("button doesn't exist in user's prefs: %s", params[0]) + } + + return b.showMoveToBtnsSettings([]string{}) +} + +func (b *Bot) moveToBtns(msgHash string) []tg.Btn { + moveToBtns := tg.NewRow() + + cmds, err := b.cfg.MoveToCmds() + if err != nil { + return nil + } + + for _, cmd := range cmds { + for _, btn := range AvailableMoveToBtns { + if btn.Cmd.Name == cmd { + btn.Cmd.Params = []string{msgHash} + moveToBtns = append(moveToBtns, btn) + break + } + } + } + + return moveToBtns +} diff --git a/server/bot_test.go b/server/bot_test.go new file mode 100644 index 0000000..ad7058a --- /dev/null +++ b/server/bot_test.go @@ -0,0 +1,4839 @@ +package server + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + "unicode/utf8" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/zakirullin/files.md/server/db" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/journal" + "github.com/zakirullin/files.md/server/pkg/tg" + "github.com/zakirullin/files.md/server/pkg/txt" + "github.com/zakirullin/files.md/server/userconfig" +) + +func init() { + fs.Ctime = func(fi os.FileInfo) int64 { + return 0 + } +} + +// inboxMsgHash returns the hash of the nth non-header block in Chat.md, or +// a placeholder string if the inbox doesn't yet exist or has fewer entries. +// Tests use this to resolve the stable inbox-entry identifier the bot now +// carries in its callback params (previously a positional int index). +func inboxMsgHash(t *testing.T, userFS *fs.FS, nth int) string { + t.Helper() + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return "nohash" + } + blocks := readChatMsgs(content) + i := 0 + for _, block := range blocks { + if strings.HasPrefix(block, "#### ") { + continue + } + if i == nth { + return chatBlockHash(block) + } + i++ + } + return "nohash" +} + +func TestSaveFromTextMsg(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2025, 6, 29, 12, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "New task")) + r.NoError(err) + + chat, err := bot.fs.Read("/", "Chat.md") + r.NoError(err) + + r.Equal("#### 29 June, Sunday\n- [ ] `12:00` New task\n", chat) +} + +// A message containing a slash-token in the middle (e.g. "Check the /token +// please") used to be intercepted as a `/token` command invocation and never +// reached the chat. With the cmd-extraction now requiring the slashed token +// to be the entire message, this kind of note must save normally with the +// `/token` text intact. +func TestSaveFromTextMsgWithSlashCmdInTheMiddle(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2025, 6, 29, 12, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + const text = "Check the /token please" + rawUpdate := tgbotapi.Update{ + Message: &tgbotapi.Message{ + Chat: &tgbotapi.Chat{ID: -1}, + Text: text, + Entities: []tgbotapi.MessageEntity{{ + Type: "bot_command", + Offset: 10, // index of '/' in text + Length: 6, // length of "/token" + }}, + }, + } + err = bot.Reply(tg.NewTGUpd(rawUpdate)) + r.NoError(err) + + chat, err := bot.fs.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 29 June, Sunday\n- [ ] `12:00` Check the /token please\n", chat) +} + +// TODO Chat.md +func TestSaveFromLongTextMsg(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, strings.Repeat("a", 34))) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(tasks, 1) + + filename := "A" + strings.Repeat("a", 33) + ".md" + r.Equal(filename, tasks[0].Name) + + content, err := bot.fs.Read("notes", filename) + r.NoError(err) + r.Equal("", content) +} + +// TODO Chat.md +// +// func TestSaveFromTextMsgWithSanitize(t *testing.T) { +// r := require.New(t) +// +// mode := userconfig.DefaultConfig.Mode +// userconfig.DefaultConfig.Mode = userconfig.ModeTasks +// defer func() { +// userconfig.DefaultConfig.Mode = mode +// }() +// +// userFS, err := fs.NewFS("/", afero.NewMemMapFs()) +// r.NoError(err) +// err = userFS.CreateSystemDirs() +// r.NoError(err) +// +// tgram := tg.NewFakeTG() +// +// bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) +// err = bot.Reply(tg.NewUpd(-1, "New task/")) +// r.NoError(err) +// +// err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"c5e7dfaf771", inboxMsgHash(t, userFS, 0)}))) +// r.NoError(err) +// +// tasks, err := bot.fs.FilesAndDirs("home") +// r.NoError(err) +// +// r.Len(tasks, 1) +// r.Equal("New task/.md", tasks[0].Name) +// +// content, err := bot.fs.Read("home", "New task/.md") +// r.NoError(err) +// r.Equal("New task/", content) +// +// err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) +// r.NoError(err) +// +// err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("add_item", []string{"db0a776589b", inboxMsgHash(t, userFS, 0)}))) +// r.NoError(err) +// +// r.Equal("1 items"+wideSpacer, tgram.LastSentText) +// r.Equal(tg.NewKeyboard([]tg.Row{ +// tg.NewBtn("👀 New task/", tg.NewCmd("task", []string{"home", "24e70ffbf48"})), +// }, +// ), tgram.LastSentKeyboard) +// } +func TestAddMultilineNoteToNotes(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "New task\nContent")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + + r.Len(tasks, 1) + r.Equal("New task.md", tasks[0].Name) + r.True(tasks[0].IsMultiline) + + content, err := bot.fs.Read("notes", "New task.md") + r.NoError(err) + r.Equal("Content", content) +} + +func TestAddNoteWithSpecCharsToNotes(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + userFS.CreateSystemDirs() + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "New task\nUrl! https://g.com (Also_text] ##header\n-item1\n-item2\n1+1=2")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + + r.Len(tasks, 1) + r.Equal("New task.md", tasks[0].Name) + r.True(tasks[0].IsMultiline) + + content, err := bot.fs.Read("notes", "New task.md") + r.NoError(err) + r.Equal("Url! https://g.com (Also_text] ##header\n-item1\n-item2\n1+1=2", content) +} + +func TestAddNoteWithOnlyWhitespace(t *testing.T) { + // Test adding a task that contains only whitespace characters + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + err = bot.Reply(tg.NewUpd(-1, " \t\n")) + r.EqualError(err, "save: empty message") + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(tasks, 0) +} + +func TestAddNoteWithLeadingAndTrailingSpaces(t *testing.T) { + // Test adding a task with leading and trailing spaces in the name + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + userFS.CreateSystemDirs() + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.Reply(tg.NewUpd(-1, " Task with spaces ")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(tasks, 1) + r.Equal("Task with spaces.md", tasks[0].Name) +} + +func TestShowEmptyTodayList(t *testing.T) { + // Test displaying today's tasks when there are none + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + r.Equal("🌴 Nothing here yet - send me something!", tgram.LastSentText) +} + +func TestSaveFromTextMsgWithUnicodeCharacters(t *testing.T) { + // Test handling of text messages containing Unicode characters + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + unicodeText := "测试含有Unicode字符的文本🚀🌟" + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, unicodeText)) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(tasks, 1) + r.Equal("测试含有Unicode字符的文本🚀🌟.md", tasks[0].Name) +} + +func TestSaveFromEmptyTextMsg(t *testing.T) { + // Test handling of empty text messages + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "")) + r.EqualError(err, "save: empty message") + + tasks, err := bot.fs.FilesAndDirs("home") + r.NoError(err) + r.Len(tasks, 0) +} + +func TestSaveFromRegularReply(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("home", "Existing file.md", "Existing content") + r.NoError(err) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + database.SetHashOrPathByMsgID(255, "home/Existing file.md") + + bot := NewBot(-1, tgram, userFS, database, fakeConfig()) + + upd := tg.NewUpd(-1, "Line") + upd.ReplyToMessageID = 255 + err = bot.Reply(upd) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("home") + r.NoError(err) + r.Len(files, 1) + + content, err := bot.fs.Read("home", "Existing file.md") + r.NoError(err) + r.Equal("Existing content\n\nLine\n", content) +} + +func TestSaveFromPhotoWithCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "Caption" + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` ![](media/tg_PHOTO_ID)\nCaption\n", content) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(files, 1) + r.Equal("Caption.md", files[0].Name) + r.True(files[0].IsMultiline) + + content, err = bot.fs.Read("notes", "Caption.md") + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)\nCaption", content) +} + +func TestSaveFromPhotoWithLongCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = strings.Repeat("a", 34) + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` ![](media/tg_PHOTO_ID)\nAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", content) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + content, err = bot.fs.Read("notes", "A"+strings.Repeat("a", 33)+".md") + r.NoError(err) + r.Equal(fmt.Sprintf("![](media/tg_PHOTO_ID)\nA%s", strings.Repeat("a", 33)), content) +} + +func TestSaveFromPhotoWithSanitizedCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "Caption/" + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` ![](media/tg_PHOTO_ID)\nCaption/\n", content) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + + r.Len(files, 1) + r.Equal("Caption/.md", files[0].Name) + r.True(files[0].IsMultiline) + + content, err = bot.fs.Read("notes", "Caption/.md") + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)\nCaption/", content) +} + +func TestSaveFromPhotoWithoutCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + userFS.CreateDirsIfNotExist("notes") + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` ![](media/tg_PHOTO_ID)\n", content) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + + r.Len(files, 1) + // Be aware that it's not regular ꞉ + r.Equal("Img 11.08.24 09꞉54.md", files[0].Name) + r.True(files[0].IsMultiline) + + // Be aware that it's not regular ꞉ + content, err = bot.fs.Read("notes", "Img 11.08.24 09꞉54.md") + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)", content) +} + +func TestSaveFromReplyPhotoWithCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("home", "Existing file.md", "Existing content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + database := db.NewFakeDB() + database.SetHashOrPathByMsgID(255, "home/Existing file.md") + + bot := NewBot(-1, tgram, userFS, database, fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "Caption" + upd.ReplyToMessageID = 255 + err = bot.Reply(upd) + r.NoError(err) + + content, err := bot.fs.Read("home", "Existing file.md") + r.NoError(err) + r.Equal("Existing content\n\n![](media/tg_PHOTO_ID)\nCaption\n", content) +} + +func TestAddTaskToLater(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.CreateSystemDirs()) + r.NoError(userFS.Write(fs.DirUserRoot, fs.ChatFilename, "- [ ] First task")) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + r.NoError(bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv_later", []string{chatBlockHash("- [ ] First task")})))) + + todayMD, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.NotContains(todayMD, "First task") + + laterMD, err := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.NoError(err) + r.Contains(laterMD, "- [ ] First task") +} + +func completeTask(t *testing.T, initial, hashed string) string { + t.Helper() + + savedNow := now + t.Cleanup(func() { now = savedNow }) + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + r := require.New(t) + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write("/", "Chat.md", initial)) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + r.NoError(bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("c", []string{chatBlockHash(hashed)})))) + + got, err := userFS.Read("/", "Chat.md") + r.NoError(err) + return got +} + +func TestCompleteTask_Incomplete_NoTimestamp_NoHeader(t *testing.T) { + got := completeTask(t, "- [ ] New task", "- [ ] New task") + require.Equal(t, "- [x] New task", got) +} + +func TestCompleteTask_Incomplete_WithTimestamp_NoHeader(t *testing.T) { + got := completeTask(t, "- [ ] `00:00` New task", "- [ ] `00:00` New task") + require.Equal(t, "- [x] `00:00` New task", got) +} + +func TestCompleteTask_Incomplete_NoTimestamp_WithHeader(t *testing.T) { + got := completeTask(t, + "#### 1 January, Thursday\n- [ ] New task", + "- [ ] New task", + ) + require.Equal(t, "#### 1 January, Thursday\n- [x] New task", got) +} + +func TestCompleteTask_Incomplete_WithTimestamp_WithHeader(t *testing.T) { + got := completeTask(t, + "#### 1 January, Thursday\n- [ ] `09:30` New task", + "- [ ] `09:30` New task", + ) + require.Equal(t, "#### 1 January, Thursday\n- [x] `09:30` New task", got) +} + +// Both todayBlockHash and CompleteChecklistItem hash the first line after +// the marker, so the box must still flip even when the block has +// continuation lines attached. +func TestCompleteTask_Multiline_FlipsBox(t *testing.T) { + block := "- [ ] `00:00` First task\nsome continuation" + got := completeTask(t, block, block) + require.Contains(t, got, "- [x] `00:00` First task") + require.NotContains(t, got, "- [ ] `00:00` First task") +} + +func TestCompleteTask_AlreadyCompleted_IsNoOp(t *testing.T) { + got := completeTask(t, "- [x] Done task", "- [x] Done task") + require.Equal(t, "- [x] Done task", got) +} + +func TestCompleteTask_Pomodoro_AddsSchedule(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + cfg := fakeConfig() + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), cfg) + + r.NoError(bot.togglePomodoro(nil)) + + pomodoroBlock := "- [ ] " + fs.PomodoroTask + before := time.Now().Unix() + r.NoError(bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("c", []string{chatBlockHash(pomodoroBlock)})))) + after := time.Now().Unix() + + todayMD, err := userFS.Read("/", fs.ChatFilename) + r.NoError(err) + r.Contains(todayMD, "- [x] "+fs.PomodoroTask) + + schedules, err := cfg.Schedules() + r.NoError(err) + r.Len(schedules, 1) + r.Equal(fs.PomodoroTask, schedules[0].Filename) + + durationSec := int64(cfg.PomodoroDuration().Seconds()) + r.GreaterOrEqual(schedules[0].ScheduledAt, before+durationSec) + r.LessOrEqual(schedules[0].ScheduledAt, after+durationSec) +} + +func TestCompleteTask_NonPomodoro_DoesNotSchedule(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write("/", fs.ChatFilename, "- [ ] Regular task")) + + cfg := fakeConfig() + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), cfg) + + r.NoError(bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("c", []string{chatBlockHash("- [ ] Regular task")})))) + + schedules, err := cfg.Schedules() + r.NoError(err) + r.Empty(schedules) +} + +func TestCompleteChecklistItem_Pomodoro_DoesNotSchedule(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write("/", fs.ChatFilename, "- [ ] "+fs.PomodoroTask)) + + cfg := fakeConfig() + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), cfg) + + r.NoError(bot.completeChecklistItem([]string{fs.Hash(fs.ChatFilename), fs.Hash(fs.PomodoroTask)})) + + schedules, err := cfg.Schedules() + r.NoError(err) + r.Empty(schedules) +} + +func TestToday(t *testing.T) { + r := require.New(t) + + savedCtime := fs.Ctime + defer func() { + fs.Ctime = savedCtime + }() + fs.Ctime = func(fi os.FileInfo) int64 { + return 0 + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "- [ ] `00:00` First task\n- [ ] `00:00` Second task") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + + r.Equal("2 items"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("First task", tg.NewCmd("c", []string{"060f6b7c9c8"})), + tg.NewBtn("🥈 Second task", tg.NewCmd("c", []string{"083d6c37d07"})), + }, + ), tgram.LastSentKeyboard) +} + +func TestTodayQuickMenuFilled(t *testing.T) { + savedCtime := fs.Ctime + defer func() { + fs.Ctime = savedCtime + }() + fs.Ctime = func(fi os.FileInfo) int64 { + return 0 + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + cfg := fakeConfig() + _ = cfg.AddQuickCmd("files") + _ = cfg.AddQuickCmd("checklists") + _ = cfg.AddQuickCmd("postpone") + bot, tgram, r := makeBot(t, cfg) + err := bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + r.Equal("1 item"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("First task", tg.NewCmd("c", []string{"832a6c2a713"})), + tg.NewRow( + tg.NewBtn("📄", tg.NewCmd("files", nil)), + tg.NewBtn("☑️", tg.NewCmd("checklists", nil)), + tg.NewBtn("🦥", tg.NewCmd("postpone", nil)), + ), + }, + ), tgram.LastSentKeyboard) +} + +func TestTodayMultilineTaskShownAsLong(t *testing.T) { + r := require.New(t) + + savedCtime := fs.Ctime + defer func() { fs.Ctime = savedCtime }() + fs.Ctime = func(fi os.FileInfo) int64 { return 0 } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { userconfig.DefaultConfig.Mode = mode }() + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "- [ ] `00:00` First task\nsome continuation") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + + hash := inboxMsgHash(t, userFS, 0) + r.Equal("1 item"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("👀 First task", tg.NewCmd(CmdShowLongItem, []string{hash})), + }), tgram.LastSentKeyboard) +} + +func TestTodayMixedSingleAndMultilineTasks(t *testing.T) { + r := require.New(t) + + savedCtime := fs.Ctime + defer func() { fs.Ctime = savedCtime }() + fs.Ctime = func(fi os.FileInfo) int64 { return 0 } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { userconfig.DefaultConfig.Mode = mode }() + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + content := "- [ ] `00:00` First task\n" + + "- [ ] `00:00` Second task\nextra detail line" + err = userFS.Write("/", "Chat.md", content) + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + + first := inboxMsgHash(t, userFS, 0) + second := inboxMsgHash(t, userFS, 1) + r.Equal("2 items"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("First task", tg.NewCmd(CmdComplete, []string{first})), + tg.NewBtn("👀 Second task", tg.NewCmd(CmdShowLongItem, []string{second})), + }), tgram.LastSentKeyboard) +} + +// TODO Chat.md +//func TestTodayWithMultilineTasks(t *testing.T) { +// r := require.New(t) +// +// savedCtime := fs.Ctime +// defer func() { +// fs.Ctime = savedCtime +// }() +// fs.Ctime = func(fi os.FileInfo) int64 { +// return 0 +// } +// +// savedNow := now +// defer func() { +// now = savedNow +// }() +// now = func() time.Time { +// return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) +// } +// +// mode := userconfig.DefaultConfig.Mode +// userconfig.DefaultConfig.Mode = userconfig.ModeTasks +// defer func() { +// userconfig.DefaultConfig.Mode = mode +// }() +// +// userFS, err := fs.NewFS("/", afero.NewMemMapFs()) +// r.NoError(err) +// err = userFS.Write("home", "First task.md", "content") +// r.NoError(err) +// err = userFS.Write("home", "Second task.md", "") +// r.NoError(err) +// +// tgram := tg.NewFakeTG() +// +// upd := tg.NewUpdCmd(-1, tg.NewCmd("home", nil)) +// bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) +// err = bot.Reply(upd) +// r.NoError(err) +// +// r.Equal("2 items"+wideSpacer, tgram.LastSentText) +// r.Equal(tg.NewKeyboard([]tg.Row{ +// tg.NewBtn("👀 First task", tg.NewCmd("task", []string{"home", "0824149b387"})), +// tg.NewBtn("🥈 Second task", tg.NewCmd("c", []string{"home", "4eb62f93b3e"})), +// }, +// ), tgram.LastSentKeyboard) +//} + +func TestFiles(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("", "Doc1.md", "") + r.NoError(err) + err = userFS.Write("", "Doc2.md", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("files", nil))) + r.NoError(err) + + r.Equal("📄 Your files:"+wideSpacer, tgram.SentTexts[0]) + r.Equal(tg.NewKeyboard([]tg.Row{ + []tg.Btn{ + tg.NewBtn("Doc1", tg.NewCmd("file", []string{fs.DirUserRoot, "c1253521ac7"})), + tg.NewBtn("Doc2", tg.NewCmd("file", []string{fs.DirUserRoot, "64572c3093f"})), + }, + []tg.Btn{ + tg.NewBtn("🔎 Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }, + }), tgram.LastSentKeyboard) +} + +func TestChecklists(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Checklist1_.md", "") + r.NoError(err) + err = userFS.Write("/", "Checklist2_.md", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("checklists", nil))) + r.NoError(err) + + r.Equal("☑️ Checklists", tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Checklist1", tg.NewCmd("checklist", []string{"3755fd335a8"})), + tg.NewBtn("Checklist2", tg.NewCmd("checklist", []string{"c54b67d1d3d"})), + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }, + ), tgram.LastSentKeyboard) +} + +func TestAddSingleItemToChecklist(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.MakeDir("-checklist1-") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.Reply(tg.NewUpd(-1, "Item")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv_to_chk", []string{inboxMsgHash(t, userFS, 0), "-checklist1-"}))) + r.NoError(err) + + files, err := userFS.FilesAndDirs("-checklist1-") + r.NoError(err) + r.Len(files, 1) + r.Equal("Item.md", files[0].Name) + + files, err = userFS.FilesAndDirs("home") + r.NoError(err) + r.Len(files, 0) +} + +func TestAddMultipleItemsToChecklist(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.MakeDir("-checklist1-") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.Reply(tg.NewUpd(-1, "Item\nItem2\nItem3")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv_to_chk", []string{inboxMsgHash(t, userFS, 0), "-checklist1-"}))) + r.NoError(err) + + files, err := userFS.FilesAndDirs("-checklist1-") + r.NoError(err) + r.Len(files, 3) + r.ElementsMatch([]string{"Item.md", "Item2.md", "Item3.md"}, []string{files[0].Name, files[1].Name, files[2].Name}) +} + +func TestShowChecklist(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Checklist1_.txt", "- [ ] Item") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("checklist", []string{"Checklist1_.txt"}))) + r.NoError(err) + + r.Equal("Checklist1"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Item", tg.NewCmd("check_item", []string{"Checklist1_.txt", "7d74f3b92b1"})), + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }, + ), tgram.LastSentKeyboard) +} + +func TestCompleteItemInChecklist(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.MakeDir("_checklist1_") + r.NoError(err) + err = userFS.Write("_checklist1_", "Item.md", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("check_comp", []string{"49d872c025c", "7b72407ca70"}))) + r.NoError(err) + + r.Equal("Checklist1"+wideSpacer, tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }, + ), tgram.LastSentKeyboard) + + items, err := bot.fs.FilesAndDirs("_checklist1_") + r.NoError(err) + r.Empty(items) + + items, err = bot.fs.FilesAndDirs("archive") + r.NoError(err) + r.Len(items, 1) + r.Equal("Item.md", items[0].Name) +} + +func TestBotTodayLabelIcons(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + tgram := tg.NewFakeTG() + b := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + // Pomodoro is the only task in today + r.Nil(b.togglePomodoro(nil)) + label := b.homeLabel() + r.Contains(label, "🌴") + r.Contains(label, "🍅") + + // Pomodoro and another task in today + todayMD, err := b.fs.Read("", "Chat.md") + r.NoError(err) + r.Nil(b.fs.Write("", "Chat.md", txt.AddChecklistItem(todayMD, "Task", false))) + label = b.homeLabel(1) + r.NotContains(label, "🌴") + r.Contains(label, "🍅") + + // No pomodoro, but there is another task in today + r.Nil(b.completeChecklistItem([]string{fs.Hash(fs.ChatFilename), fs.Hash(fs.PomodoroTask)})) + label = b.homeLabel(1) + r.NotContains(label, "🌴") + r.NotContains(label, "🍅") + + // No pomodoro, no other tasks in today + r.Nil(b.completeChecklistItem([]string{fs.Hash(fs.ChatFilename), fs.Hash("Task")})) + label = b.homeLabel() + r.NoError(err) + r.Contains(label, "🌴") + r.NotContains(label, "🍅") +} + +func makeBot(t *testing.T, cfg *userconfig.Config) (*Bot, *tg.FakeTG, *require.Assertions) { + r := require.New(t) + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.Write("/", "Chat.md", "- [ ] First task") + r.NoError(err) + + err = userFS.Write("/", "Later.md", "- [ ] Second task") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + return bot, tgram, r +} + +func TestSettingsMainPanel(t *testing.T) { + bot, tgram, r := makeBot(t, fakeConfig()) + err := bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("settings", nil))) + r.NoError(err) + r.Equal("Settings:", tgram.LastSentText) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("🧠 Full mode", tg.NewCmd("full", nil)), + //tg.NewBtn("💬 Inbox mode", tg.NewCmd("chat", nil)), + tg.NewBtn("📌 Notes mode", tg.NewCmd("notes_only", nil)), + tg.NewBtn("✅ Tasks mode", tg.NewCmd("tasks_only", nil)), + //tg.NewBtn("💚 Journal mode", tg.NewCmd("journal_only", nil)), + tg.NewBtn("-", tg.NewCmd("nothing", nil)), + tg.NewBtn("⚡️ Quick buttons", tg.NewCmd("c_quick_btns", nil)), + tg.NewBtn("➡️ Move to buttons", tg.NewCmd("c_move_btns", nil)), + tg.NewBtn("🌎 Timezone", tg.NewCmd("timezone", nil)), + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }, + ), tgram.LastSentKeyboard) +} + +// Quick Panel Data-driven tests + +var ( + btnFilesDel = tg.NewBtn("📄 Files ➖", tg.NewCmd("del_quick", []string{"files"})) + btnChecklistsDel = tg.NewBtn("☑️ Checklists ➖", tg.NewCmd("del_quick", []string{"checklists"})) + btnPostponeDel = tg.NewBtn("🦥 Postpone ➖", tg.NewCmd("del_quick", []string{"postpone"})) +) + +var ( + delimiter = tg.NewBtn("-", tg.NewCmd("nothing", nil)) + homeBtn = tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)) +) + +var ( + btnLater = tg.NewBtn("⏳ Later ➕", tg.NewCmd("add_quick", []string{"later"})) + btnSearch = tg.NewBtn("🔎 Search ➕", tg.NewCmd("add_quick", []string{"search"})) + btnFilesAdd = tg.NewBtn("📄 Files ➕", tg.NewCmd("add_quick", []string{"files"})) + btnChecklistsAdd = tg.NewBtn("☑️ Checklists ➕", tg.NewCmd("add_quick", []string{"checklists"})) + btnPostponeAdd = tg.NewBtn("🦥 Postpone ➕", tg.NewCmd("add_quick", []string{"postpone"})) + btnReadChecklist = tg.NewBtn("📚 Read ➕", tg.NewCmd("add_quick", []string{"read"})) + btnWatchChecklist = tg.NewBtn("📺 Watch ➕", tg.NewCmd("add_quick", []string{"watch"})) + btnShopChecklist = tg.NewBtn("🛒 Shop ➕", tg.NewCmd("add_quick", []string{"shop"})) + btnSchedule = tg.NewBtn("🗓 Schedule ➕", tg.NewCmd("add_quick", []string{"schedule"})) + btnHabits = tg.NewBtn("🌱 Habits ➕", tg.NewCmd("add_quick", []string{"habits"})) + btnRandom = tg.NewBtn("🎲 Random ➕", tg.NewCmd("add_quick", []string{"random_note"})) +) + +func TestConfigureQP_Empty_Default(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{""}, + tg.NewUpdCmd(-1, tg.NewCmd("c_quick_btns", nil)), + []tg.Row{ + delimiter, + btnLater, + btnSearch, + btnFilesAdd, + btnChecklistsAdd, + btnPostponeAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_Empty_AddFiles(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{""}, + tg.NewUpdCmd(-1, tg.NewCmd("add_quick", []string{"files"})), + []tg.Row{ + btnFilesDel, + delimiter, + btnLater, + btnSearch, + btnChecklistsAdd, + btnPostponeAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_Doc_AddCheckList(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"files"}, + tg.NewUpdCmd(-1, tg.NewCmd("add_quick", []string{"checklists"})), + []tg.Row{ + btnFilesDel, + btnChecklistsDel, + delimiter, + btnLater, + btnSearch, + btnPostponeAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_DocChecklists_AddPostpone(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"files", "checklists"}, + tg.NewUpdCmd(-1, tg.NewCmd("add_quick", []string{"postpone"})), + []tg.Row{ + btnFilesDel, + btnChecklistsDel, + btnPostponeDel, + delimiter, + btnLater, + btnSearch, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_DocChecklistsPostpone_Show(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"files", "checklists", "postpone"}, + tg.NewUpdCmd(-1, tg.NewCmd("c_quick_btns", nil)), + []tg.Row{ + btnFilesDel, + btnChecklistsDel, + btnPostponeDel, + delimiter, + btnLater, + btnSearch, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_DocChecklistsPostpone_DelChecklists(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"files", "checklists", "postpone"}, + tg.NewUpdCmd(-1, tg.NewCmd("del_quick", []string{"checklists"})), + []tg.Row{ + btnFilesDel, + btnPostponeDel, + delimiter, + btnLater, + btnSearch, + btnChecklistsAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_DocPostpone_DelDoc(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"files", "postpone"}, + tg.NewUpdCmd(-1, tg.NewCmd("del_quick", []string{"files"})), + []tg.Row{ + btnPostponeDel, + delimiter, + btnLater, + btnSearch, + btnFilesAdd, + btnChecklistsAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_Postpone_DelPostpone(t *testing.T) { + RunQuickPanelTc(PrefTableTestCase{ + []string{"postpone"}, + tg.NewUpdCmd(-1, tg.NewCmd("del_quick", []string{"postpone"})), + []tg.Row{ + delimiter, + btnLater, + btnSearch, + btnFilesAdd, + btnChecklistsAdd, + btnPostponeAdd, + btnReadChecklist, + btnWatchChecklist, + btnShopChecklist, + btnSchedule, + btnHabits, + btnRandom, + homeBtn, + }, + }, t) +} + +func TestConfigureQP_Empty_AddUnknown(t *testing.T) { + RunquickpaneltcError(PrefTableTestCase{ + []string{""}, + tg.NewUpdCmd(-1, tg.NewCmd("add_quick", []string{"wrong"})), + []tg.Row{}, + }, "unknown command: wrong", t) +} + +func RunQuickPanelTc(tc PrefTableTestCase, t *testing.T) { + cnf := fakeConfig() + for _, cmd := range tc.existingCmds { + _ = cnf.AddQuickCmd(cmd) + } + + bot, tgram, r := makeBot(t, cnf) + + err := bot.Reply(tc.updToAnswer) + r.NoError(err) + r.Equal("Configure quick buttons (➕ = add to quick buttons, ➖ = to remove from quick buttons):", tgram.LastSentText) + r.Equal(tg.NewKeyboard(tc.buttons), tgram.LastSentKeyboard) +} + +func RunquickpaneltcError(tc PrefTableTestCase, expectedErr string, t *testing.T) { + cnf := fakeConfig() + for _, cmd := range tc.existingCmds { + _ = cnf.AddQuickCmd(cmd) + } + bot, _, r := makeBot(t, cnf) + actualErr := bot.Reply(tc.updToAnswer) + r.EqualError(actualErr, expectedErr) +} + +type PrefTableTestCase struct { + existingCmds []string + updToAnswer *tg.Upd + buttons []tg.Row +} + +func TestShowToFileNoDirs(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Note.md", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + const h = "fakehash" + err = bot.showMoveToFileOrDir([]string{h}) + r.NoError(err) + + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewRow(tg.NewBtn("Note", tg.NewCmd("mf", []string{"345fb", h}))), + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow(tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{h}))), + }, + ), tgram.LastSentKeyboard) +} + +func TestShowMoveToFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("notes", "Note.md", "") + r.NoError(err) + err = userFS.MakeDir("dir") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + const h = "fakehash" + err = bot.showMoveToFileOrDir([]string{h}) + r.NoError(err) + + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂️ Dir", tg.NewCmd("mv", []string{"73600", h})), + tg.NewBtn("🗂️ Notes", tg.NewCmd("mv", []string{"4358b", h})), + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{h})), + ), + }), tgram.LastSentKeyboard) +} + +func TestShow(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showHTML("text", nil) + r.NoError(err) + + r.Equal("text", tgram.LastSentText) +} + +func TestShowMDLongMessage(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showMD(strings.Repeat("a", 4096)+"b", nil) + r.NoError(err) + + r.Len(tgram.SentTexts, 2) + r.Equal("b", tgram.LastSentText) +} + +// When utf8.RuneCountInString(textChunk) == 4096, tg sends the message (len(textChunk) => 7003) +// if I have 4095 chars and add 🟢, we have 4096 chars, and it is ok +// if I have 4095 chars and add ⚪️, we have 4097 chars, and we fail, so tg doesn't operate on glyph clusters +func TestShowMDLongMessageWithColoredEmojis(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showMD(strings.Repeat("a", 4095)+"🟢", nil) + r.NoError(err) + + r.Len(tgram.SentTexts, 1) +} + +func TestShowMDLongMessageWithColoredEmoji(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showMD(strings.Repeat("a", 4095)+"⚪️", nil) + r.NoError(err) + + r.Len(tgram.SentTexts, 2) +} + +func TestShowMDLongMessageSplitByNewLine(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showMD(strings.Repeat("a", 4094)+"\nabc", nil) + r.NoError(err) + + r.Len(tgram.SentTexts, 2) + r.Equal("abc", tgram.LastSentText) +} + +func TestShowMDLongMessageAttachKeyboardToTheLast(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.showMD(strings.Repeat("a", 4094)+"\nabc", tg.NewKeyboard([]tg.Row{tg.NewBtn("btn", tg.NewCmd("cmd", nil))})) + r.NoError(err) + + r.Len(tgram.SentTexts, 2) + r.Equal("abc", tgram.LastSentText) + r.NotNil(tgram.LastSentKeyboard) + r.Len(tgram.LastSentKeyboard.Btns, 1) +} + +func TestMoveToExistingFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "#### 27 June, Friday\n`12:00` New message") + r.NoError(err) + err = userFS.Write("", "Existing file.md", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"1c8f819d075", inboxMsgHash(t, userFS, 0)})) + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("", "Existing file.md") + r.NoError(err) + r.Equal("#### 11 August 2024, Sunday\nNew message", content) +} + +func TestMoveToExistingFileExistingRecord(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "#### 27 June, Friday\n`12:00` New message") + r.NoError(err) + err = userFS.Write("", "Existing file.md", "### 11.08.2024 Sunday\nContent") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"1c8f819d075", inboxMsgHash(t, userFS, 0)})) + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("", "Existing file.md") + r.NoError(err) + r.Equal("#### 11 August 2024, Sunday\nNew message\n\n### 11.08.2024 Sunday\nContent", content) +} + +func TestShowMoveTo(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("", "file", "") + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.Reply(tg.NewUpd(-1, "New task\nContent")) + r.NoError(err) + + r.Equal("Saved!", tgram.SentTexts[0]) + + h := inboxMsgHash(t, userFS, 0) + kb := tg.NewKeyboard([]tg.Row{ + []tg.Btn{ + {Name: "🌚 To tmrw", Cmd: tg.Cmd{Name: "sc_tmrw", Params: []string{h}, Type: "cmd"}}, + {Name: "⏳ To later", Cmd: tg.Cmd{Name: "mv_later", Params: []string{h}, Type: "cmd"}}, + {Name: "📆 To a day", Cmd: tg.Cmd{Name: "sc_day", Params: []string{h}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "📄 To File", Cmd: tg.Cmd{Name: "to_file", Params: []string{h}, Type: "cmd"}}, + {Name: "💚 To Journal", Cmd: tg.Cmd{Name: "mv_to_journal", Params: []string{h}, Type: "cmd"}}, + }, + }, + ) + r.Equal(kb, tgram.LastSentKeyboard) +} + +func TestShowMoveFromTodayAndInbox(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [ ] `09:00` Inbox body\n- [x] `09:05` Completed body\n", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("move", nil))) + r.NoError(err) + + inboxHash := inboxMsgHash(t, userFS, 0) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("💬 Inbox body", tg.NewCmd("s_move", []string{inboxHash})), + tg.NewRow( + tg.NewBtn("Rename", tg.NewCmd("rename", []string{})), + tg.NewBtn("OK", tg.NewCmd("home", []string{})), + ), + }), tgram.LastSentKeyboard) +} + +func TestMoveFromTodayAndInbox_ToLater(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "- [ ] Today task\n#### 29 June, Sunday\n- [ ] `09:00` Inbox body", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + // Click the inbox button → moveToLater ships the entry to Later.md. + // Block 0 is the top-level "Today task"; the inbox body is block 1. + inboxHash := inboxMsgHash(t, userFS, 1) + r.NoError(bot.moveToLater([]string{inboxHash})) + + inboxMD, _ := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NotContains(inboxMD, "Inbox body") + + laterMD, _ := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.Contains(laterMD, "- [ ] Inbox body") +} + +func TestShowPostpone_WithInbox(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [ ] `09:00` Inbox body\n- [x] `09:05` Completed body\n", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("postpone", nil))) + r.NoError(err) + + inboxHash := inboxMsgHash(t, userFS, 0) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("💬 Inbox body", tg.NewCmd("post", []string{inboxHash})), + tg.NewRow( + tg.NewBtn("Rename", tg.NewCmd("rename", []string{})), + tg.NewBtn("OK", tg.NewCmd("home", []string{})), + ), + }), tgram.LastSentKeyboard) +} + +func TestPostponeTodayTask(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write(fs.DirUserRoot, fs.ChatFilename, "- [ ] Today task")) + r.NoError(userFS.Write(fs.DirUserRoot, fs.LaterFilename, "")) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.postpone([]string{fs.Hash("Today task")}) + r.NoError(err) + + todayMD, _ := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NotContains(todayMD, "Today task") + + laterMD, _ := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.Contains(laterMD, "- [ ] Today task") +} + +func TestPostponeInboxEntry(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [ ] `09:00` Inbox body", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + h := inboxMsgHash(t, userFS, 0) + err = bot.postpone([]string{h}) + r.NoError(err) + + inboxMD, _ := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NotContains(inboxMD, "Inbox body") + + laterMD, _ := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.Contains(laterMD, "- [ ] Inbox body") +} + +func TestShowRename_WithInbox(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [ ] `09:00` Inbox body\n- [x] `09:05` Completed body\n", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + r.NoError(bot.showRename(nil)) + + inboxHash := inboxMsgHash(t, userFS, 0) + r.Equal(tg.NewKeyboard([]tg.Row{ + tg.NewBtn("💬 Inbox body", tg.NewCmd("rename_file", []string{fs.ChatFilename, inboxHash})), + tg.NewBtn("🏠 Home", tg.NewCmd("home", nil)), + }), tgram.LastSentKeyboard) +} + +func TestRenameInboxEntry(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 9, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [ ] `09:00` Inbox body\n- [ ] `09:05` Second entry", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + h := inboxMsgHash(t, userFS, 0) + r.NoError(bot.rename([]string{fs.ChatFilename, h, "Renamed body"})) + + inboxMD, _ := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + // Timestamp and marker preserved, body replaced. + r.Contains(inboxMD, "- [ ] `09:00` Renamed body") + r.NotContains(inboxMD, "Inbox body") + // Untouched entry stays intact. + r.Contains(inboxMD, "- [ ] `09:05` Second entry") +} + +func TestRenameInboxEntry_PreservesCompletedMarker(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(userFS.Write( + fs.DirUserRoot, fs.ChatFilename, + "#### 29 June, Sunday\n- [x] `09:00` Done body", + )) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + h := inboxMsgHash(t, userFS, 0) + r.NoError(bot.rename([]string{fs.ChatFilename, h, "Renamed done"})) + + inboxMD, _ := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.Contains(inboxMD, "- [x] `09:00` Renamed done") +} + +// Reproduces the "msgHash ... not found in inbox" error that users hit when +// forwarding multiple messages: message 1 produces a keyboard carrying +// msgHash1, message 2 is collapsed into the same inbox block as a +// continuation line, which shifts the block's hash. The original msgHash1 +// from the keyboard then fails to resolve. +func TestForwardCollapse_HashStableAcrossContinuations(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2025, 6, 29, 14, 35, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + u1 := tg.NewUpd(-1, "one\ntwo\nthree") + u1.TimeVal = 100 + u1.HasTimeVal = true + r.NoError(bot.Reply(u1)) + + msgHash1, ok := firstMsgHash(-1) + r.True(ok, "firstMsgHash should be stored for msg 1") + + u2 := tg.NewUpd(-1, "two") + u2.TimeVal = 100 + u2.HasTimeVal = true + r.NoError(bot.Reply(u2)) + + // The keyboard on msg 1 still carries msgHash1. After the collapsed append, + // moveFromInbox must still resolve it to the first block. + var gotContent string + err = bot.moveFromChat(func(content string, _ time.Time) error { + gotContent = content + return nil + }, false, msgHash1) + r.NoError(err) + r.Contains(gotContent, "One") + r.Contains(gotContent, "two") + r.Contains(gotContent, "three") +} + +func TestShowScheduleEmpty(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("schedule", nil))) + r.NoError(err) + + r.Equal("You don't have any scheduled tasks! 🌴", tgram.SentTexts[0]) +} + +func TestShowSchedule(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + savedSchedNow := now + defer func() { + now = savedSchedNow + }() + now = func() time.Time { + return time.Date(1970, 10, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.AddToSchedule("filename.md", 0, "") + r.NoError(err) + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("schedule", nil))) + r.NoError(err) + + r.Equal("01 January, Thursday\n- Filename", tgram.SentTexts[0]) +} + +func TestMoveToChecklistSplittable(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.MakeDir("-checklist-") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "item1\nitem2")) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` Item1\nitem2\n", content) + + err = bot.moveToDirChecklist([]string{inboxMsgHash(t, userFS, 0), "-checklist-"}) + r.NoError(err) + + files, err := userFS.FilesAndDirs("-checklist-") + r.NoError(err) + + items := fs.OnlyFilenames(files) + r.ElementsMatch([]string{"Item1.md", "Item2.md"}, items) +} + +func fakeConfig() *userconfig.Config { + userFS, _ := fs.NewFS("/-1", afero.NewMemMapFs()) + cfg := userconfig.NewConfig(userFS, -1, "config.json") + _ = cfg.CreateDefaultIfNotExists() + + return cfg +} + +func TestExtractCmd(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "jj journal record") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + + r.NotNil(cmd) + r.Equal("j", cmd.Name) + r.Equal([]string{"Journal record"}, cmd.Params) +} + +func TestExtractCmdRu(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "жж запись в журнал") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + + r.NotNil(cmd) + r.Equal("j", cmd.Name) + r.Equal([]string{"Запись в журнал"}, cmd.Params) +} + +func TestExtractCmdSkipsInTheBeginning(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "jjj task for tomorrow") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + + r.Nil(cmd) +} + +func TestExtractCmdSkipsAtTheMiddle(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "journal jj record") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + r.Nil(cmd) +} + +func TestExtractCmdSkipsInTheEnd(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "task for tomorrow jjj") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + r.Nil(cmd) +} + +func TestExtractCmdAtTheEnd(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "journal record jj") + cmd, err := bot.extractCmd(upd) + r.NoError(err) + + r.NotNil(cmd) + r.Equal("j", cmd.Name) + r.Equal([]string{"Journal record"}, cmd.Params) +} + +func TestMoveToJournal(t *testing.T) { + r := require.New(t) + + savedNow := journal.Now + defer func() { + journal.Now = savedNow + }() + journal.Now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.Write("/", "Chat.md", "#### 27 June, Friday\n`01:01` Multiline\ncontent") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := userFS.FilesAndDirs("journal") + r.NoError(err) + r.Len(files, 1) + + content, err := userFS.Read("journal", files[0].Name) + r.NoError(err) + r.Equal("## 1 January, Thursday\n`00:00` Multiline\ncontent\n", content) + + content, err = userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 27 June, Friday", content) +} + +func TestAddToJournalFromShortcut(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "jj record")) + r.NoError(err) + + files, err := userFS.FilesAndDirs("journal") + r.NoError(err) + r.Len(files, 1) +} + +func TestAddToJournalFromShortcutRu(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "/ж запись")) + r.NoError(err) + + files, err := userFS.FilesAndDirs("journal") + r.NoError(err) + r.Len(files, 1) +} + +func TestAddToJournalFromShortcutRuCases(t *testing.T) { + r := require.New(t) + + savedNow := journal.Now + defer func() { + journal.Now = savedNow + }() + journal.Now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, "жЖ запись")) + r.NoError(err) + + files, err := userFS.FilesAndDirs("journal") + r.NoError(err) + r.Len(files, 1) + + content, err := userFS.Read("journal", files[0].Name) + r.NoError(err) + r.Equal("## 1 January, Thursday\n`00:00` Запись\n", content) + + err = bot.Reply(tg.NewUpd(-1, "Запись2 ЖЖ")) + r.NoError(err) + + content, err = userFS.Read("journal", files[0].Name) + r.NoError(err) + r.Equal("## 1 January, Thursday\n`00:00` Запись\n`00:00` Запись2\n", content) +} + +func TestShowForADay(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc_day", []string{"1c8f819d075"}))) + r.NoError(err) + + r.Equal(tg.NewKeyboard([]tg.Row{ + []tg.Btn{{Name: "🔄️ Repeat the task", Cmd: tg.Cmd{Name: "sc_day_r", Params: []string{"1c8f819d075"}, Type: "cmd"}}}, + []tg.Btn{ + {Name: "Mon", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "345600", ""}, Type: "cmd"}}, + {Name: "Tue", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "432000", ""}, Type: "cmd"}}, + {Name: "Wed", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "518400", ""}, Type: "cmd"}}, + {Name: "Thu", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "604800", ""}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "Fri", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "86400", ""}, Type: "cmd"}}, + {Name: "Sat", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "172800", ""}, Type: "cmd"}}, + {Name: "Sun", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "259200", ""}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "1", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2678400", ""}, Type: "cmd"}}, + {Name: "2", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "86400", ""}, Type: "cmd"}}, + {Name: "3", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "172800", ""}, Type: "cmd"}}, + {Name: "4", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "259200", ""}, Type: "cmd"}}, + {Name: "5", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "345600", ""}, Type: "cmd"}}, + {Name: "6", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "432000", ""}, Type: "cmd"}}, + {Name: "7", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "518400", ""}, Type: "cmd"}}, + {Name: "8", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "604800", ""}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "9", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "691200", ""}, Type: "cmd"}}, + {Name: "10", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "777600", ""}, Type: "cmd"}}, + {Name: "11", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "864000", ""}, Type: "cmd"}}, + {Name: "12", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "950400", ""}, Type: "cmd"}}, + {Name: "13", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1036800", ""}, Type: "cmd"}}, + {Name: "14", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1123200", ""}, Type: "cmd"}}, + {Name: "15", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1209600", ""}, Type: "cmd"}}, + {Name: "16", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1296000", ""}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "17", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1382400", ""}, Type: "cmd"}}, + {Name: "18", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1468800", ""}, Type: "cmd"}}, + {Name: "19", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1555200", ""}, Type: "cmd"}}, + {Name: "20", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1641600", ""}, Type: "cmd"}}, + {Name: "21", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1728000", ""}, Type: "cmd"}}, + {Name: "22", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1814400", ""}, Type: "cmd"}}, + {Name: "23", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1900800", ""}, Type: "cmd"}}, + {Name: "24", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "1987200", ""}, Type: "cmd"}}, + }, + []tg.Btn{ + {Name: "25", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2073600", ""}, Type: "cmd"}}, + {Name: "26", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2160000", ""}, Type: "cmd"}}, + {Name: "27", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2246400", ""}, Type: "cmd"}}, + {Name: "28", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2332800", ""}, Type: "cmd"}}, + {Name: "29", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2419200", ""}, Type: "cmd"}}, + {Name: "30", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2505600", ""}, Type: "cmd"}}, + {Name: "31", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f819d075", "2592000", ""}, Type: "cmd"}}, + }, + tg.Btn{Name: "➡️ Move to today", Cmd: tg.Cmd{Name: "home", Params: []string(nil), Type: "cmd"}}, + }), tgram.LastSentKeyboard) +} + +func TestShowForADayRecurring(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc_day_r", []string{"1c8f819d075"}))) + r.NoError(err) + + r.Equal(tg.NewKeyboard([]tg.Row{ + []tg.Btn{ + {Name: "Weekdays", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "86400", "0 0 * * 1-5"}, Type: "cmd"}}, + {Name: "Every day", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "86400", "0 0 * * *"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "Mon", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "345600", "0 0 * * 1"}, Type: "cmd"}}, + {Name: "Tue", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "432000", "0 0 * * 2"}, Type: "cmd"}}, + {Name: "Wed", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "518400", "0 0 * * 3"}, Type: "cmd"}}, + {Name: "Thu", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "604800", "0 0 * * 4"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "Fri", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "86400", "0 0 * * 5"}, Type: "cmd"}}, + {Name: "Sat", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "172800", "0 0 * * 6"}, Type: "cmd"}}, + {Name: "Sun", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "259200", "0 0 * * 0"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "1", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "2678400", "0 0 1 * *"}, Type: "cmd"}}, + {Name: "2", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "86400", "0 0 2 * *"}, Type: "cmd"}}, + {Name: "3", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "172800", "0 0 3 * *"}, Type: "cmd"}}, + {Name: "4", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "259200", "0 0 4 * *"}, Type: "cmd"}}, + {Name: "5", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "345600", "0 0 5 * *"}, Type: "cmd"}}, + {Name: "6", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "432000", "0 0 6 * *"}, Type: "cmd"}}, + {Name: "7", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "518400", "0 0 7 * *"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "8", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "604800", "0 0 8 * *"}, Type: "cmd"}}, + {Name: "9", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "691200", "0 0 9 * *"}, Type: "cmd"}}, + {Name: "10", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "777600", "0 0 10 * *"}, Type: "cmd"}}, + {Name: "11", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "864000", "0 0 11 * *"}, Type: "cmd"}}, + {Name: "12", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "950400", "0 0 12 * *"}, Type: "cmd"}}, + {Name: "13", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1036800", "0 0 13 * *"}, Type: "cmd"}}, + {Name: "14", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1123200", "0 0 14 * *"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "15", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1209600", "0 0 15 * *"}, Type: "cmd"}}, + {Name: "16", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1296000", "0 0 16 * *"}, Type: "cmd"}}, + {Name: "17", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1382400", "0 0 17 * *"}, Type: "cmd"}}, + {Name: "18", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1468800", "0 0 18 * *"}, Type: "cmd"}}, + {Name: "19", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1555200", "0 0 19 * *"}, Type: "cmd"}}, + {Name: "20", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1641600", "0 0 20 * *"}, Type: "cmd"}}, + {Name: "21", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1728000", "0 0 21 * *"}, Type: "cmd"}}, + }, []tg.Btn{ + {Name: "22", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1814400", "0 0 22 * *"}, Type: "cmd"}}, + {Name: "23", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1900800", "0 0 23 * *"}, Type: "cmd"}}, + {Name: "24", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "1987200", "0 0 24 * *"}, Type: "cmd"}}, + {Name: "25", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "2073600", "0 0 25 * *"}, Type: "cmd"}}, + {Name: "26", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "2160000", "0 0 26 * *"}, Type: "cmd"}}, + {Name: "27", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "2246400", "0 0 27 * *"}, Type: "cmd"}}, + {Name: "28", Cmd: tg.Cmd{Name: "sc", Params: []string{"1c8f", "2332800", "0 0 28 * *"}, Type: "cmd"}}, + }, tg.Btn{Name: "➡️ Move to today", Cmd: tg.Cmd{Name: "home", Params: []string(nil), Type: "cmd"}}, + }), tgram.LastSentKeyboard) +} + +func TestSchedule(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + err = bot.Reply(tg.NewUpd(-1, "Task")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc", []string{inboxMsgHash(t, userFS, 0), "345600", "0 0 * * 1-5"}))) + r.NoError(err) + + tasksForToday, err := userFS.FilesAndDirs("home") + r.NoError(err) + r.Empty(tasksForToday) + + laterMD, err := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.NoError(err) + + items, _ := txt.ChecklistItems(laterMD) + r.Contains(items, "Task") + + sc, err := cfg.Schedules() + r.NoError(err) + r.Len(sc, 1) + r.Equal("Task", sc[0].Filename) + r.Equal(int64(345600), sc[0].ScheduledAt) + r.Equal("0 0 * * 1-5", sc[0].Cron) +} + +// The recurring picker truncates the chat msgHash to its 4-char prefix to fit +// Telegram's 64-byte callback_data limit when a cron expression is also packed +// in. moveFromChat must accept the prefix and still locate the inbox entry. +func TestScheduleRepeatedWithTruncatedHash(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + err = bot.Reply(tg.NewUpd(-1, "Task")) + r.NoError(err) + + fullHash := inboxMsgHash(t, userFS, 0) + truncated := fullHash[:4] + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc", []string{truncated, "345600", "0 0 * * 1-5"}))) + r.NoError(err) + + laterMD, err := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.NoError(err) + items, _ := txt.ChecklistItems(laterMD) + r.Contains(items, "Task") + + sc, err := cfg.Schedules() + r.NoError(err) + r.Len(sc, 1) + r.Equal("Task", sc[0].Filename) + r.Equal("0 0 * * 1-5", sc[0].Cron) +} + +func TestScheduleNoRepeat(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + err = bot.Reply(tg.NewUpd(-1, "Task")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc", []string{inboxMsgHash(t, userFS, 0), "345600", ""}))) + r.NoError(err) + + tasksForToday, err := userFS.FilesAndDirs("home") + r.NoError(err) + r.Empty(tasksForToday) + + laterMD, err := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.NoError(err) + + items, _ := txt.ChecklistItems(laterMD) + r.Contains(items, "Task") + + sc, err := cfg.Schedules() + r.NoError(err) + r.Len(sc, 1) + r.Equal("Task", sc[0].Filename) + r.Equal(int64(345600), sc[0].ScheduledAt) +} + +func TestInlineRequestTask(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("dir", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, " dir / File ") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.NoError(err) + + r.Equal("File\n\nFile content", tgram.LastSentText) +} + +func TestInlineRequestFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, " File ") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.NoError(err) + + r.Equal("File\n\nFile content", tgram.LastSentText) +} + +func TestInlineRequestFileOutsideTheDirectory(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "../File") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.Error(err) + r.EqualError(err, "insecure input '../File': invalid request from inline query") +} + +func TestInlineRequestFileOutsideTheDirectoryTwoSlashes(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "..//File") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.Error(err) + r.EqualError(err, "insecure input '..//File': invalid request from inline query") +} + +func TestInlineRequestFileListOutsideDirs(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "../") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.Error(err) + r.EqualError(err, "insecure input '../': invalid request from inline query") +} + +func TestInlineRequestFileListRootDirs(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + // cd /tmp//.. would lead to root + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "/..") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.Error(err) + r.EqualError(err, "insecure input '/..': invalid request from inline query") +} + +func TestInlineRequestFileListRootDirsWithoutSlash(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + // cd /tmp/.. would lead to root + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "..") + upd.IsSentViaBotVal = true + + err = bot.Reply(upd) + r.Error(err) + r.EqualError(err, "show file: can't find file: can't unhash '..' in '/': cannot unhash, maybe the file is missing") +} + +func TestAnswerSearch(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + u := tg.NewUpd(-1, "") + u.InlineQueryVal = "File" + u.IsInlineQueryVal = true + + err = bot.answerSearch(u) + r.NoError(err) + + var results []interface{} + article := tgbotapi.NewInlineQueryResultArticleHTML("0", "File", "File.md") + results = append(results, article) + + r.Equal(results, tgram.InlineQueryResults) +} + +func TestAnswerSearchShowAllRoot(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + err = userFS.MakeDir("Dir") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + u := tg.NewUpd(-1, "") + u.InlineQueryVal = "File" + u.IsInlineQueryVal = true + + err = bot.answerSearch(u) + r.NoError(err) + + var results []interface{} + article := tgbotapi.NewInlineQueryResultArticleHTML("0", "File", "File.md") + results = append(results, article) + + r.Equal(results, tgram.InlineQueryResults) +} + +func TestAnswerSearchShowOutsideTheRoot(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + err = userFS.MakeDir("Dir") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + u := tg.NewUpd(-1, "") + u.InlineQueryVal = "../" + u.IsInlineQueryVal = true + + err = bot.answerSearch(u) + r.Error(err) + r.EqualError(err, "insecure input '../': invalid inline query") +} + +func TestAnswerSearchShowOutsideTheRootNoSlash(t *testing.T) { + r := require.New(t) + + memFS := afero.NewMemMapFs() + err := afero.WriteFile(memFS, "/secret", []byte("secret"), 0o644) + r.NoError(err) + + userFS, err := fs.NewFS("/-1", memFS) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.Write("", "File.md", "File content") + r.NoError(err) + err = userFS.MakeDir("Dir") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + u := tg.NewUpd(-1, "") + u.InlineQueryVal = ".." + u.IsInlineQueryVal = true + + err = bot.answerSearch(u) + r.Error(err) + r.EqualError(err, "inline reply: search notes: exists: unsafe path '/': unsafe path, possible security issue") +} + +func TestShowFileEscapesHTML(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("", "File.md", "bold*italic*") + r.NoError(err) + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + err = bot.showFile([]string{"/", "File.md"}) + r.NoError(err) + r.Equal("File\n\n<b>bolditalic", tgram.LastSentText) +} + +func TestSaveToNewTask(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "New task")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + content, err := userFS.Read("notes", "New task.md") + r.NoError(err) + r.Equal("", content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, msgID) + r.Equal(msgID, tgram.LastSentMessageID) +} + +func TestSaveToExistingFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.Write("/", "Chat.md", "#### 27 June, Friday\n`01:01` Existing\nmessage") + r.NoError(err) + err = userFS.Write("", "File.md", "#### 1 January 1970, Thursday\nExisting content") + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "New content")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 1)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("File", tg.NewCmd("mf", []string{"7595e", inboxMsgHash(t, userFS, 1)})), + ), + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 1)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"7595e", inboxMsgHash(t, userFS, 1)}))) + r.NoError(err) + + r.Nil(tgram.LastEditedKeyboard) + + content, err := userFS.Read("", "File.md") + r.NoError(err) + r.Equal("#### 1 January 1970, Thursday\nExisting content\nNew content", content) + + r.Nil(database.InputExpectation()) + keybdMsgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, keybdMsgID) + r.Equal(3, tgram.LastSentMessageID) +} + +func TestSaveToExistingFileModeTasks(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.Write("/", "Chat.md", "#### 27 June, Friday\n`01:01` New\ncontent") + r.NoError(err) + err = userFS.Write("", "File.md", "#### 1 January 1970, Thursday\nExisting\ncontent") + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Text")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 1)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("File", tg.NewCmd("mf", []string{"7595e", inboxMsgHash(t, userFS, 1)})), + ), + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 1)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"7595e", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + r.Nil(tgram.LastEditedKeyboard) + + content, err := userFS.Read("", "File.md") + r.NoError(err) + r.Equal("#### 1 January 1970, Thursday\nExisting\ncontent\nNew\ncontent", content) + + r.Nil(database.InputExpectation()) + keybdMsgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, keybdMsgID) + r.Equal(3, tgram.LastSentMessageID) +} + +func TestSaveToNewFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "#### 1 January, Thursday\n`01:01` New\ncontent") + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Text")) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 1 January, Thursday\n`01:01` New\ncontent\n- [ ] `00:00` Text\n", content) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 1)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 1)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 1)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 1)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + //err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)}))) + //r.NoError(err) + err = bot.Reply(tg.NewUpd(-1, "Myfile")) + r.NoError(err) + + content, err = userFS.Read("/", "Myfile.md") + r.NoError(err) + r.Equal("Text", content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(1, msgID) + r.Equal(2, tgram.LastSentMessageID) +} + +func TestSaveToNewDirFull(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("/", "Chat.md", "#### 1 January, Thursday\n") + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Text")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + r.Equal("OK. Send me the name for your new dir", tgram.LastEditedText) + r.Nil(tgram.LastEditedKeyboard) + r.Equal(tg.NewCmd("mv_to_new_dir", []string{inboxMsgHash(t, userFS, 0), "%s"}), *database.InputExpectation()) + + err = bot.Reply(tg.NewUpd(-1, "My dir")) + r.NoError(err) + + r.Equal("🌴 Nothing here yet - send me something!", tgram.LastSentText) + + content, err := userFS.Read("my dir", "Text.md") + r.NoError(err) + r.Empty(content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, msgID) + r.Equal(3, tgram.LastSentMessageID) +} + +func TestSaveToNewDir(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Text")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + r.Equal("OK. Send me the name for your new dir", tgram.LastEditedText) + r.Nil(tgram.LastEditedKeyboard) + r.Equal(tg.NewCmd("mv_to_new_dir", []string{inboxMsgHash(t, userFS, 0), "%s"}), *database.InputExpectation()) + + err = bot.Reply(tg.NewUpd(-1, "My dir")) + r.NoError(err) + + r.Equal("🌴 Nothing here yet - send me something!", tgram.LastSentText) + + content, err := userFS.Read("my dir", "Text.md") + r.NoError(err) + r.Empty(content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, msgID) + r.Equal(3, tgram.LastSentMessageID) +} + +func TestSaveToNewMultilineFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("", "Text.md", "") + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Multiline\ncontent")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("Text", tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)})), + ), + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + r.Empty(tgram.LastEditedKeyboard) + + content, err := userFS.Read("/", "Text.md") + r.NoError(err) + r.Equal("#### 1 January 1970, Thursday\nMultiline\ncontent", content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(3, msgID) + r.Equal(msgID, tgram.LastSentMessageID) +} + +func TestSaveToNewCustomFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "Text")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + selectFileKB := tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)})), + ), + }) + r.Equal(selectFileKB, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpd(-1, "new file")) + r.NoError(err) + + r.Empty(tgram.LastEditedKeyboard.Btns) + + content, err := userFS.Read("", "New file.md") + r.NoError(err) + r.Equal("Text", content) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(1, msgID) + r.Equal(2, tgram.LastSentMessageID) +} + +func TestSaveToRecentFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("", "Text.md", "Text") + r.NoError(err) + err = userFS.MakeDir("dir") + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + _ = cfg.AddMoveToCmd(CmdScheduleForTmrw) + _ = cfg.AddMoveToCmd(CmdMoveToLater) + _ = cfg.AddMoveToCmd(CmdShowScheduleForDay) + _ = cfg.AddMoveToCmd(CmdShowMoveToDirOrFile) + _ = cfg.AddMoveToCmd(CmdMoveToJournal) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "New text")) + r.NoError(err) + + kb := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + selectFileKeyboard := tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("Text", tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)})), + ), + tg.NewBtn("Search", tg.NewCustomCmd("search", nil, "iq")), + tg.NewRow( + tg.NewBtn("🗂️ Dir", tg.NewCmd("mv", []string{"73600", inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("🗂 New Dir", tg.NewCmd("new_dir", []string{inboxMsgHash(t, userFS, 0)})), + ), + }) + r.Equal(selectFileKeyboard, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + r.Empty(tgram.LastEditedKeyboard) + + content, err := userFS.Read("", "Text.md") + r.NoError(err) + r.Equal("#### 1 January 1970, Thursday\nNew text\n\nText", content) + + recentCMD, ok := database.RecentCommand() + r.Equal("mf", recentCMD) + r.True(ok) + + // Adding text again to see if we have a recent file + err = bot.Reply(tg.NewUpd(-1, "Text2")) + r.NoError(err) + + kb = tg.NewKeyboard([]tg.Row{ + tg.NewRow( + tg.NewBtn("🌚 To tmrw", tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⏳ To later", tg.NewCmd("mv_later", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("📆 To a day", tg.NewCmd("sc_day", []string{inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("📄 To File", tg.NewCmd("to_file", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("💚 To Journal", tg.NewCmd("mv_to_journal", []string{inboxMsgHash(t, userFS, 0)})), + tg.NewBtn("⭐️ Text", tg.NewCmd("mf", []string{"23200", inboxMsgHash(t, userFS, 0)})), + ), + tg.NewRow( + tg.NewBtn("👌", tg.NewCmd("home", []string{})), + ), + }) + r.Equal(kb, tgram.LastSentKeyboard) + + r.Nil(database.InputExpectation()) + msgID, ok := database.LastKeyboardMsgID() + r.True(ok) + r.Equal(4, msgID) + r.Equal(msgID, tgram.LastSentMessageID) +} + +func TestSaveToTodayTask(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.Write("", "Chat.md", txt.AddChecklistItem("", "Existing task", false)) + r.NoError(err) + + cfg := userconfig.NewConfig(userFS, -1, "config.json") + err = cfg.CreateDefaultIfNotExists() + r.NoError(err) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + bot := NewBot(-1, tgram, userFS, database, cfg) + err = bot.Reply(tg.NewUpd(-1, "New task")) + r.NoError(err) + + // Click "move to checklist + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + // Block 0 is the top-level "Existing task"; the inbox entry "New task" is block 1. + kb := tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Existing task", tg.NewCmd("c", []string{"04fcfb3c2ef"})), + tg.NewBtn("New task", tg.NewCmd("c", []string{"58d765d4752"})), + }) + + r.Equal(kb, tgram.LastEditedKeyboard) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("c", []string{"58d765d4752"}))) + r.NoError(err) + + kb = tg.NewKeyboard([]tg.Row{ + tg.NewBtn("Existing task", tg.NewCmd("c", []string{"04fcfb3c2ef"})), + }) + r.Equal(kb, tgram.LastEditedKeyboard) +} + +func TestCollapseToMsg(t *testing.T) { + var userID int64 = -1 + r := require.New(t) + + clean := func() { + firstMsgHashes.Range(func(key, value interface{}) bool { + firstMsgTimes.Delete(key) + return true + }) + firstMsgTimes.Range(func(key, value interface{}) bool { + firstMsgTimes.Delete(key) + return true + }) + } + clean() + + // Collapse same second messages + setFirstMsgHash(userID, "h", 100) + setFirstMsgTime(userID, 100) + _, shouldCollapse := collapseToMsg(userID, 100) + r.True(shouldCollapse) + //r.Equal("file1.md", filename) + clean() + + // Collapse next second messages + setFirstMsgHash(userID, "h", 100) + setFirstMsgTime(userID, 100) + _, shouldCollapse = collapseToMsg(userID, 101) + require.True(t, shouldCollapse, "Expected to collapse the message") + //require.Equal(t, "file2.md", filename, "Expected filename to match the first message") + clean() + + // Do not collapse distant messages + setFirstMsgHash(userID, "h", 100) + setFirstMsgTime(userID, 100) + _, shouldCollapse = collapseToMsg(userID, 103) + require.False(t, shouldCollapse, "Expected not to collapse the message") + //require.Empty(t, filename, "Expected no filename for non-collapsing messages") + clean() + + // Collapse consecutive batch messages + setFirstMsgHash(userID, "h", 200) + setFirstMsgTime(userID, 200) + // Loop to simulate a series of consecutive messages within a one-second interval + for i := 0; i < 5; i++ { + _, shouldCollapse = collapseToMsg(userID, 200+i) + require.True(t, shouldCollapse, "Expected to collapse the message in the batch") + //require.Equal(t, "file4.md", filename, "Expected filename to match the initial message") + setFirstMsgTime(userID, 200+i) + } + clean() +} + +func TestCollapseForwardedMessages(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + tgram := tg.NewFakeTG() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + upd := tg.NewUpd(-1, "First msg") + upd.TimeVal = 0 + upd.HasTimeVal = true + err = bot.Reply(upd) + r.NoError(err) + + upd = tg.NewUpd(-1, "Second msg") + upd.TimeVal = 0 + upd.HasTimeVal = true + err = bot.Reply(upd) + r.NoError(err) + + upd = tg.NewUpd(-1, "Third msg") + upd.TimeVal = 1 + upd.HasTimeVal = true + err = bot.Reply(upd) + r.NoError(err) + + upd = tg.NewUpd(-1, "Fourth msg") + upd.TimeVal = 3 + upd.HasTimeVal = true + err = bot.Reply(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 1 January, Thursday\n- [ ] `00:00` First msg\nSecond msg\nThird msg\n- [ ] `00:00` Fourth msg\n", content) + + // Clean + firstMsgHashes.Range(func(key, value interface{}) bool { + firstMsgTimes.Delete(key) + return true + }) + firstMsgTimes.Range(func(key, value interface{}) bool { + firstMsgTimes.Delete(key) + return true + }) +} + +func TestTitleChecklist(t *testing.T) { + r := require.New(t) + + title := checklistTitle("_checklist_") + r.Equal("Checklist", title) +} + +func TestTitleChecklistItem(t *testing.T) { + r := require.New(t) + + title := checklistTitle("_checklist_item") + r.Equal("Item", title) +} + +func TestRestoreMsg_EmptyMessage(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "Empty.md" + err = userFS.Write("home", filename, "") + r.NoError(err) + + title, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal("Empty", title) +} + +func TestRestoreMsg_ContentWithoutTitle(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "NewTask.md" + content := "Some content that doesn't include the title" + err = userFS.Write("home", filename, content) + r.NoError(err) + + msg, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal("NewTask\nSome content that doesn't include the title", msg) +} + +func TestRestoreMsg_ContentWithTitle(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "Task.md" + content := "Task\nDetails about the task" + err = userFS.Write("home", filename, content) + r.NoError(err) + + msg, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal(content, msg) +} + +func TestRestoreMsg_ContentWithSanitizedTitle(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "Task/Slash.md" + content := "Task/Slash\nDetails about the task" + err = userFS.Write("home", filename, content) + r.NoError(err) + + msg, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal(content, msg) +} + +func TestRestoreMsg_WithImage(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "Caption.md" + content := "![img](tg_url.jpg)\nCaption" + err = userFS.Write("home", filename, content) + r.NoError(err) + + msg, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal(content, msg) +} + +func TestRestoreMsg_WithImageSanitizedFilename(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + filename := "Caption/File" + content := "![img](tg_url.jpg)\nCaption/File" + err = userFS.Write("home", filename, content) + r.NoError(err) + + msg, err := bot.restoreMsg("home", filename) + r.NoError(err) + r.Equal(content, msg) +} + +func TestSaveFromImage_NewFile(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "New Image" + + err = bot.saveFromImage(upd) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(files, 1) + r.Equal("New Image.md", files[0].Name) + + content, err := bot.fs.Read("notes", "New Image.md") + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)\nNew Image", content) +} + +func TestSaveFromImage_LongCaption(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = strings.Repeat("a", 34) + + err = bot.saveFromImage(upd) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + filename := "A" + strings.Repeat("a", 33) + ".md" + content, err := bot.fs.Read("notes", filename) + r.NoError(err) + r.Equal(fmt.Sprintf("![](media/tg_PHOTO_ID)\nA%s", strings.Repeat("a", 33)), content) +} + +func TestSaveFromImage_MultilineCaption(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "abc\ndef" + + err = bot.saveFromImage(upd) + r.NoError(err) + + content, err := userFS.Read("/", "Chat.md") + r.NoError(err) + r.Equal("#### 11 August, Sunday\n- [ ] `09:54` ![](media/tg_PHOTO_ID)\nAbc\ndef\n", content) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + filename := fmt.Sprintf("Abc.md") + content, err = bot.fs.Read("notes", filename) + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)\nAbc\ndef", content) +} + +func TestSaveFromImage_ReplyToExistingFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + // Setup in-memory filesystem and add an existing file + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write("home", "Existing file.md", "Existing content") + r.NoError(err) + + tgram := tg.NewFakeTG() + database := db.NewFakeDB() + database.SetHashOrPathByMsgID(255, "home/Existing file.md") + + bot := NewBot(-1, tgram, userFS, database, fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + upd.PhotoCaption = "Image Caption" + upd.ReplyToMessageID = 255 + + err = bot.saveFromImage(upd) + r.NoError(err) + + content, err := bot.fs.Read("home", "Existing file.md") + r.NoError(err) + r.Equal("Existing content\n\n![](media/tg_PHOTO_ID)\nImage Caption\n", content) +} + +func TestSaveFromImage_EmptyCaption(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + userFS.CreateDirsIfNotExist("notes") + + tgram := tg.NewFakeTG() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + + upd := tg.NewUpd(-1, "") + upd.PhotoID = "PHOTO_ID" + + err = bot.saveFromImage(upd) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + files, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + r.Len(files, 1) + r.Equal("Img 01.01.70 00꞉00.md", files[0].Name) + + content, err := bot.fs.Read("notes", "Img 01.01.70 00꞉00.md") + r.NoError(err) + r.Equal("![](media/tg_PHOTO_ID)", content) +} + +func TestCreateOrAdd_NewFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + dir := "home" + filename := "NewFile.md" + content := "This is new content" + + err = bot.createOrAdd(dir, filename, content) + r.NoError(err) + + storedContent, err := bot.fs.Read(dir, filename) + r.NoError(err) + r.Equal(content, storedContent) +} + +func TestCreateOrAdd_AppendToExistingFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + dir := "home" + filename := "ExistingFile.md" + existingContent := "Existing content" + newContent := "New content" + + err = userFS.Write(dir, filename, existingContent) + r.NoError(err) + + err = bot.createOrAdd(dir, filename, newContent) + r.NoError(err) + + expectedContent := "Existing content\nNew content" + storedContent, err := bot.fs.Read(dir, filename) + r.NoError(err) + r.Equal(expectedContent, storedContent) +} + +func TestCreateOrAdd_ReplaceEmptyContent(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, nil, userFS, nil, nil) + + dir := "home" + filename := "EmptyFile.md" + newContent := "New content" + + err = userFS.Write(dir, filename, "\n") + r.NoError(err) + + err = bot.createOrAdd(dir, filename, newContent) + r.NoError(err) + + storedContent, err := bot.fs.Read(dir, filename) + r.NoError(err) + r.Equal(newContent, storedContent) +} + +func TestExtractTitleAndContent_EmptyMessage(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + title, content, err := bot.extractHeaderAndBody("", 33) + r.Error(err) + r.Contains(err.Error(), "extract title: empty msg") + r.Equal("", title) + r.Equal("", content) +} + +func TestExtractTitleAndContent_SimpleMessage(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + msg := "Simple DisplayName" + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal("Simple DisplayName", title) + r.Equal("", content) +} + +func TestExtractTitleAndContent_MultilineMessage(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + msg := "DisplayName Line\nThis is the content" + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal("DisplayName Line", title) + r.Equal("This is the content", content) +} + +func TestExtractTitleAndContent_TitleExceedsMaxLength(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + longTitle := strings.Repeat("a", 33+1) + msg := longTitle + "\nContent below" + expectedTitle := txt.Substr(txt.Ucfirst(longTitle), 0, 33) + "..." + + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal(expectedTitle, title) + r.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nContent below", content) +} + +func TestExtractTitleAndContent_TitleSameAsContent(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + msg := "Identical DisplayName" + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal("Identical DisplayName", title) + r.Equal("", content) +} + +func TestExtractTitleAndContent_ContentStartsWithTitle(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + msg := "DisplayName Line\nDisplayName Line\nAdditional content" + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal("DisplayName Line", title) + r.Equal("DisplayName Line\nAdditional content", content) +} + +func TestExtractTitleAndContent_TitleNeedsSanitization(t *testing.T) { + r := require.New(t) + + bot := NewBot(-1, nil, nil, nil, nil) + + msg := "Invalid/DisplayName?Name\nContent here" + + title, content, err := bot.extractHeaderAndBody(msg, 33) + r.NoError(err) + r.Equal("Invalid/DisplayName?Name", title) + r.Equal("Invalid/DisplayName?Name\nContent here", content) +} + +func TestMoveToExistingNote_Success(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.MakeDir("notes") + r.NoError(err) + err = userFS.Write("notes", "ExistingNote.md", "Existing content\n") + r.NoError(err) + + tgram := tg.NewFakeTG() + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + err = bot.Reply(tg.NewUpd(-1, "Task content")) + r.NoError(err) + + toDirHash := fs.Hash("notes") + toFilenameHash := fs.Hash("ExistingNote.md") + err = bot.moveToExistingNote([]string{toFilenameHash, toDirHash, inboxMsgHash(t, userFS, 0)}) + r.NoError(err) + + content, err := userFS.Read("notes", "ExistingNote.md") + r.NoError(err) + r.Equal("#### 1 January 1970, Thursday\nTask content\n\nExisting content\n", content) + + _, err = userFS.Read("home", "Task.md") + r.Error(err) +} + +func TestMoveToExistingNote_FileNotFound(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + toDirHash := fs.Hash("notes") + toFilenameHash := fs.Hash("ExistingNote.md") + fromFilenameHash := fs.Hash("Task.md") + + err = bot.moveToExistingNote([]string{toFilenameHash, toDirHash, fromFilenameHash}) + r.Error(err) +} + +func TestMoveToExistingNote_InvalidIndex(t *testing.T) { + r := require.New(t) + + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeFull + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + tgram := tg.NewFakeTG() + cfg := fakeConfig() + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + + toDirHash := "invalidHash" + toFilenameHash := "invalidHash" + msgIndex := "-1" + + err = bot.moveToExistingNote([]string{toFilenameHash, toDirHash, msgIndex}) + r.Error(err) + r.Contains(err.Error(), "move to existing note") +} + +// TODO move to Chat.md +func FuzzSaveFromTextMsg(f *testing.F) { + mode := userconfig.DefaultConfig.Mode + userconfig.DefaultConfig.Mode = userconfig.ModeTasks + defer func() { + userconfig.DefaultConfig.Mode = mode + }() + + seedInputs := []string{ + "Normal task", + "Special char /\\:*?|<>", + "Emoji 😃🚀", + strings.Repeat("a", 5000), + } + for _, input := range seedInputs { + f.Add(input) + } + f.Add(".") + f.Add("..") + f.Add("/today/..md") + f.Add("/valid/path") + f.Add("../file") + f.Add("../../file") + f.Add("../../../file") + + f.Fuzz(func(t *testing.T, input string) { + if len(strings.TrimSpace(input)) == 0 { + return + } + + // Telegram trims space + input = strings.TrimSpace(input) + + // Ignore input with shortcuts like adding to journal or to recent file. + // Because in that case file won't be created in "home" folder + shortcuts := []string{"jj", "жж", "++"} + for _, shortcut := range shortcuts { + lowerInput := strings.ToLower(input) + if strings.HasPrefix(lowerInput, shortcut) || strings.HasSuffix(lowerInput, shortcut) { + return + } + } + + // fs.sanitizeName will strip away \0 characters as they're not allowed in filenames + if strings.Contains(input, "\x00") { + fmt.Println("Skipping string with null character") + return + } + + r := require.New(t) + + memfs := afero.NewMemMapFs() + _ = memfs.Mkdir("/user", 0o755) + userFS, err := fs.NewFS("/user", memfs) + userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.CreateDirsIfNotExist("notes") + r.NoError(err) + + tgram := tg.NewFakeTG() + + savedPlugins := BotPlugins + BotPlugins = []BotPlugin{} + defer func() { + BotPlugins = savedPlugins + }() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), fakeConfig()) + err = bot.Reply(tg.NewUpd(-1, input)) + if err != nil { + // Check that no entries are created besides our user folder + entries, err := afero.ReadDir(memfs, "/") + r.NoError(err) + r.Len(entries, 1) + return + } + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("mv", []string{"4358b5009c6", inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + tasks, err := bot.fs.FilesAndDirs("notes") + r.NoError(err) + + if input == "" { + r.Len(tasks, 0) + return + } + + r.Len(tasks, 1, "No tasks created for input %q", input) + title := txt.Ucfirst(strings.TrimSpace(strings.SplitN(strings.TrimSpace(input), "\n", 2)[0])) + if utf8.RuneCountInString(title) > 100 { + title = txt.Substr(title, 0, 100) + "..." + } + filename := fs.SanitizeFilename(title) + ".md" + r.Equal(filename, tasks[0].Name) + + _, err = bot.fs.Read("notes", filename) + r.NoError(err) + }) +} + +func TestJournalOnlyMode_SaveTextMessage(t *testing.T) { + r := require.New(t) + + savedNow := journal.Now + defer func() { + journal.Now = savedNow + }() + journal.Now = func() time.Time { + return time.Date(2024, 8, 11, 9, 54, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.SetMode(userconfig.ModeJournal) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.Reply(tg.NewUpd(-1, "Journal entry")) + r.NoError(err) + + todayFiles, err := bot.fs.FilesAndDirs("home") + r.NoError(err) + r.Len(todayFiles, 0) + + journalFiles, err := bot.fs.FilesAndDirs("journal") + r.NoError(err) + r.Len(journalFiles, 1) + + content, err := bot.fs.Read("journal", journalFiles[0].Name) + r.NoError(err) + r.Contains(content, "Journal entry") + r.Contains(content, "11 August, Sunday") +} + +//func TestFileOnlyMode_SaveTextMessage(t *testing.T) { +// r := require.New(t) +// +// userFS, err := fs.NewFS("/", afero.NewMemMapFs()) +// r.NoError(err) +// err = userFS.CreateSystemDirs() +// r.NoError(err) +// +// tgram := tg.NewFakeTG() +// +// cfg := fakeConfig() +// err = cfg.SetMode(userconfig.ModeOneFile) +// r.NoError(err) +// +// bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) +// err = bot.Reply(tg.NewUpd(-1, "File content")) +// r.NoError(err) +// +// todayFiles, err := bot.fs.FilesAndDirs("home") +// r.NoError(err) +// r.Len(todayFiles, 0) +// +// rootFiles, err := bot.fs.FilesAndDirs("/") +// r.NoError(err) +// r.True(len(rootFiles) > 0) +// +// content, err := bot.fs.Read("", "Chat.md") +// r.NoError(err) +// r.Equal("File content", content) +//} + +func TestShowToday_NotesOnlyMode(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + err = userFS.MakeDir("test-dir") + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.SetMode(userconfig.ModeNotes) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.ShowHome(nil) + r.NoError(err) + + // Should show directories instead of today tasks + // The exact message depends on showDirs implementation, but we verify it was called + r.NotEmpty(tgram.LastSentText) + // In notes-only mode, ShowToday should redirect to showing directories +} + +func TestShowToday_JournalOnlyMode(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.SetMode(userconfig.ModeJournal) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.ShowHome(nil) + r.NoError(err) + + // Should send "What's on your mind?" message + r.Contains(tgram.LastSentText, "What's on your mind?") + r.Nil(tgram.LastSentKeyboard) // No keyboard should be sent +} + +func TestShowToday_NormalMode(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + // Default mode (not notes-only, journal-only, or one-file-only) + err = cfg.SetMode(userconfig.ModeFull) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.ShowHome(nil) + r.NoError(err) + + // Should show empty today list + r.Equal("🌴 Nothing here yet - send me something!", tgram.LastSentText) +} + +func TestShowToday_NormalModeWithTasks(t *testing.T) { + r := require.New(t) + + savedCtime := fs.Ctime + defer func() { + fs.Ctime = savedCtime + }() + fs.Ctime = func(fi os.FileInfo) int64 { + return 0 + } + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + err = userFS.Write("/", "Chat.md", "- [ ] test task") + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.SetMode(userconfig.ModeFull) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.ShowHome(nil) + r.NoError(err) + + r.Contains(tgram.LastSentText, "1") + r.Contains(tgram.LastSentText, "item") + + r.Len(tgram.LastSentKeyboard.Btns, 1) +} + +// TestShowToday_InboxMixedFormat confirms ShowToday renders inbox entries in +// both the timestamped `- [ ] HH:MM body` and timestamp-less `- [ ] body` +// forms, and hides completed `- [x]` entries while keeping the on-disk index +// stable so callback params (Complete, MoveTo, ...) still point at the right line. +func TestShowToday_InboxMixedFormat(t *testing.T) { + r := require.New(t) + + savedCtime := fs.Ctime + defer func() { fs.Ctime = savedCtime }() + fs.Ctime = func(fi os.FileInfo) int64 { return 0 } + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + // Inbox has three entries at on-disk positions 0, 1, 2: + // 0: no-timestamp form `- [ ] body` -> shown + // 1: timestamped form `- [ ] HH:MM body` -> shown + // 2: completed `- [x] HH:MM body` -> hidden + inbox := "#### 1 January, Thursday\n" + + "- [ ] Plain msg\n" + + "- [ ] `09:05` New msg\n" + + "- [x] `09:10` Done msg\n" + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, inbox) + r.NoError(err) + + tgram := tg.NewFakeTG() + + cfg := fakeConfig() + err = cfg.SetMode(userconfig.ModeFull) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.ShowHome(nil) + r.NoError(err) + + // Label: 2 tasks left (plain + timestamped unchecked); completed one excluded. + r.Equal("2 items"+wideSpacer, tgram.LastSentText) + + // Two rows, each with one button. The completed entry (disk position 2) is + // not rendered but its slot is preserved — the next fresh entry added to + // the inbox would be position 3, not 2. + r.Len(tgram.LastSentKeyboard.Btns, 2) + + firstBtn, ok := tgram.LastSentKeyboard.Btns[0].(tg.Btn) + r.True(ok) + r.Equal(tg.Cmd{Name: CmdComplete, Params: []string{chatBlockHash("- [ ] Plain msg")}, Type: "cmd"}, firstBtn.Cmd) + r.Contains(firstBtn.Name, "Plain msg") + + secondBtn, ok := tgram.LastSentKeyboard.Btns[1].(tg.Btn) + r.True(ok) + // The completed `- [x] `09:10` Done msg` entry is hidden, but the + // remaining unchecked entry keeps its own stable hash (unchanged by + // completion-toggle behaviour). + r.Equal(tg.Cmd{Name: CmdComplete, Params: []string{chatBlockHash("- [ ] `09:05` New msg")}, Type: "cmd"}, secondBtn.Cmd) + r.Contains(secondBtn.Name, "New msg") +} + +func TestShowToday_TodayCommandModeJournal(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + cfg := fakeConfig() + + tgram = tg.NewFakeTG() + + err = cfg.SetMode(userconfig.ModeJournal) + r.NoError(err) + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("home", nil))) + r.NoError(err) + + r.Contains(tgram.LastSentText, "What's on your mind?") +} + +func TestScheduleForTmrw(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + + tgram := tg.NewFakeTG() + cfg := fakeConfig() + + bot := NewBot(-1, tgram, userFS, db.NewFakeDB(), cfg) + err = bot.Reply(tg.NewUpd(-1, "Task for tomorrow")) + r.NoError(err) + + err = bot.Reply(tg.NewUpdCmd(-1, tg.NewCmd("sc_tmrw", []string{inboxMsgHash(t, userFS, 0)}))) + r.NoError(err) + + laterMD, err := userFS.Read(fs.DirUserRoot, fs.LaterFilename) + r.NoError(err) + + items, _ := txt.ChecklistItems(laterMD) + r.Contains(items, "Task for tomorrow") + + sc, err := cfg.Schedules() + r.NoError(err) + r.Len(sc, 1) + r.Equal("Task for tomorrow", sc[0].Filename) + r.Equal(int64(86400), sc[0].ScheduledAt) + r.Equal("", sc[0].Cron) +} diff --git a/server/chat.go b/server/chat.go new file mode 100644 index 0000000..11b84f0 --- /dev/null +++ b/server/chat.go @@ -0,0 +1,396 @@ +package server + +import ( + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +var ( + Now = time.Now + mu sync.Mutex + userLocks map[string]*sync.Mutex + + inboxMarkerPrefix = regexp.MustCompile(`^- \[[ xX]\] `) + inboxHeaderRegex = regexp.MustCompile(`^#### `) + // inboxEntryPrefix matches the prefix of an inbox entry: `- [ ] ` / + // `- [x] ` marker followed by an optional `HH:MM` timestamp. + inboxEntryPrefix = regexp.MustCompile(`^- \[[ xX]\] (?:` + "`" + `\d{2}:\d{2}` + "` )?") +) + +// stripInboxEntryPrefix removes the optional `- [ ]` / `- [x] ` task marker +// and the “ `HH:MM` “ timestamp so only the entry body remains. +func stripInboxEntryPrefix(block string) string { + return inboxEntryPrefix.ReplaceAllString(block, "") +} + +// chatBlockHash returns a stable identifier for an inbox block. We hash only +// the timestamped first line (after stripping the `- [ ]`/`- [x] ` marker) +// so the identifier survives two mutations the bot makes to a block: +// - completion toggle `- [ ]` ↔ `- [x]` +// - forward-collapse appending continuation lines to the first block +// (see saveFromTextMsg → createOrAdd). A keyboard built for message 1 +// must still resolve after message 2 is collapsed into the same block. +// +// Collision scope: two separate entries with the same “ `HH:MM` “ timestamp +// AND identical first line would hash the same. Per-minute resolution makes +// this rare in practice, and the outcome (acting on the older entry) is +// harmless — it's still the user's own content with the same first line. +// !!! TIME IS INCLUDED in the hash !!! +func chatBlockHash(block string) string { + stripped := inboxMarkerPrefix.ReplaceAllString(block, "") + firstLine := strings.SplitN(stripped, "\n", 2)[0] + return fs.Hash(firstLine) +} + +// findChatMsgByHash returns (blockIndex, block, true) for the first +// non-header block whose hash matches msgHash. Returns (-1, "", false) if no +// match is found. +func findChatMsgByHash(content, msgHash string) (int, string, bool) { + blocks := readChatMsgs(content) + for i, block := range blocks { + if inboxHeaderRegex.MatchString(block) { + continue + } + if chatBlockHash(block) == msgHash { + return i, block, true + } + } + return -1, "", false +} + +// renameChatMsg replaces the body of the block identified by msgHash with +// newBody, preserving the `- [ ] `/`- [x] ` marker and the “ `HH:MM` “ +// timestamp. Returns the rewritten file content. +func renameChatMsg(content, msgHash, newBody string) (string, error) { + blocks := readChatMsgs(content) + idx := -1 + for i, block := range blocks { + if inboxHeaderRegex.MatchString(block) { + continue + } + if chatBlockHash(block) == msgHash { + idx = i + break + } + } + if idx == -1 { + return "", fmt.Errorf("inbox block not found for hash %q", msgHash) + } + + prefix := inboxEntryPrefix.FindString(blocks[idx]) + newBody = strings.TrimSpace(strings.ReplaceAll(newBody, "\n", " ")) + blocks[idx] = prefix + newBody + + return strings.Join(blocks, "\n"), nil +} + +// appendToChatMsg appends newText to the chat block identified by msgHash, +// preserving the marker/timestamp prefix. The appended text becomes a new +// indented continuation line under the original entry. +func appendToChatMsg(content, msgHash, newText string) (string, error) { + blocks := readChatMsgs(content) + idx := -1 + for i, block := range blocks { + if inboxHeaderRegex.MatchString(block) { + continue + } + if chatBlockHash(block) == msgHash { + idx = i + break + } + } + if idx == -1 { + return "", fmt.Errorf("chat block not found for hash %q", msgHash) + } + + newText = strings.TrimRight(newText, "\n") + if newText == "" { + return content, nil + } + blocks[idx] = strings.TrimRight(blocks[idx], "\n") + "\n" + newText + + return strings.Join(blocks, "\n"), nil +} + +// appendToChat writes a new entry to Inbox.md and returns its stable hash. +func (b *Bot) appendToChat(content string, timezone *time.Location) (string, error) { + exists, err := b.fs.Exists(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return "", fmt.Errorf("appendToInbox: %w", err) + } + + content = strings.TrimSpace(content) + + var md string + if exists { + md, err = b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return "", fmt.Errorf("appendToInbox: %w", err) + } + md = txt.NormNewLines(md) + md = strings.TrimSpace(md) + if len(md) != 0 { + md += "\n" + } + } + + // Add today's header if it doesn't exist + if !strings.Contains(md, todayHeader(timezone)) { + md += todayHeader(timezone) + "\n" + } + + // Format timestamp with timezone + // TODO should we use timezone here? + timestamp := now().In(timezone).Format("`15:04`") + + newEntry := fmt.Sprintf("- [ ] %s %s", timestamp, content) + md += newEntry + "\n" + + if err := b.fs.Write(fs.DirUserRoot, fs.ChatFilename, md); err != nil { + return "", fmt.Errorf("appendToInbox: %w", err) + } + + return chatBlockHash(newEntry), nil +} + +// moveFromChat passes the messages identified by msgHashes to the callback. +// On callback success, it removes those messages from the chat file. +// A msgHash is the stable hash returned by chatBlockHash; it survives the +// `[ ]` ↔ `[x]` completion toggle. +// On collapse=false the callback is called once per message. +func (b *Bot) moveFromChat( + callback func(content string, timestamp time.Time) error, + collapse bool, + msgHashes ...string, +) error { + key, err := b.fs.SafePath(fs.DirUserRoot, "") + if err != nil { + return fmt.Errorf("failed to get safe path: %w", err) + } + + lock := userLock(key) + lock.Lock() + defer lock.Unlock() + + content, err := b.fs.Read(fs.DirUserRoot, fs.ChatFilename) + if err != nil { + return err + } + + blocks := readChatMsgs(content) + + // Build hash -> block-index for every non-header block. Validate that all + // requested hashes resolve to real blocks. + hashToBlockIndex := make(map[string]int) + hasAnyMsg := false + for i, block := range blocks { + if inboxHeaderRegex.MatchString(block) { + continue + } + hasAnyMsg = true + hashToBlockIndex[chatBlockHash(block)] = i + } + if !hasAnyMsg { + return fmt.Errorf("no messages found") + } + resolvedBlockIndices := make([]int, 0, len(msgHashes)) + for _, h := range msgHashes { + idx, ok := hashToBlockIndex[h] + if !ok { + // Recurring-schedule callback_data is tight on Telegram's 64-byte + // limit so the picker truncates msgHash to its prefix. Fall back + // to prefix match here. + for full, i := range hashToBlockIndex { + if strings.HasPrefix(full, h) { + idx = i + ok = true + break + } + } + } + if !ok { + return fmt.Errorf("msgHash %q not found in inbox", h) + } + resolvedBlockIndices = append(resolvedBlockIndices, idx) + } + + // Process in ascending block-index order so removal later is deterministic. + sort.Ints(resolvedBlockIndices) + + // Collect specified messages from inbox. + var msgs []struct { + content string + timestamp time.Time + index int + } + for _, blockIndex := range resolvedBlockIndices { + block := blocks[blockIndex] + + // Find closest header above target msg for date context + var headerDate string + for i := blockIndex - 1; i >= 0; i-- { + if inboxHeaderRegex.MatchString(blocks[i]) { + headerDate = blocks[i] + break + } + } + + // Strip optional `- [ ] ` / `- [x] ` marker, then optional `HH:MM` + // timestamp. A plain checklist line without timestamp is treated as a + // 00:00 entry on the header date. + recordContent := inboxMarkerPrefix.ReplaceAllString(block, "") + timeStr := "00:00" + tsMatch := regexp.MustCompile("^`(\\d{2}:\\d{2})` ").FindStringSubmatch(recordContent) + if tsMatch != nil { + timeStr = tsMatch[1] + recordContent = recordContent[len(tsMatch[0]):] + } + + // Parse full timestamp from header date + time. Fall back to today + // when the entry has no `#### date` header above it (plain `- [ ] body`). + var timestamp time.Time + dateRegex := regexp.MustCompile(`^#### (\d{1,2}) ([A-Za-z]+), [A-Za-z]+`) + dateMatches := dateRegex.FindStringSubmatch(headerDate) + if len(dateMatches) >= 3 { + dateTimeStr := fmt.Sprintf("%s %s %s", dateMatches[1], dateMatches[2], timeStr) + parsed, err := time.Parse("2 January 15:04", dateTimeStr) + if err != nil { + return fmt.Errorf("failed to parse timestamp for block %d: %w", blockIndex, err) + } + timestamp = parsed + } else { + today := now() + t, err := time.Parse("15:04", timeStr) + if err == nil { + timestamp = time.Date(today.Year(), today.Month(), today.Day(), t.Hour(), t.Minute(), 0, 0, today.Location()) + } else { + timestamp = today + } + } + + msgs = append(msgs, struct { + content string + timestamp time.Time + index int + }{ + content: recordContent, + timestamp: timestamp, + index: blockIndex, + }) + } + + // First we save all the messages to files, only then we remove them from the inbox. + if collapse { + content := strings.Builder{} + for _, msg := range msgs { + content.WriteString(msg.content) + content.WriteString("\n") + } + err = callback(strings.TrimSpace(content.String()), msgs[0].timestamp) + if err != nil { + return fmt.Errorf("callback failed: %w", err) + } + } else { + for _, msg := range msgs { + if err := callback(msg.content, msg.timestamp); err != nil { + return fmt.Errorf("callback failed: %w", err) + } + } + } + + blocksToRemove := make(map[int]bool) + for _, msg := range msgs { + blocksToRemove[msg.index] = true + } + newBlocks := make([]string, 0) + for i, block := range blocks { + if blocksToRemove[i] { + continue + } + newBlocks = append(newBlocks, block) + } + modifiedContent := strings.TrimSpace(strings.Join(newBlocks, "\n")) + + return b.fs.Write(fs.DirUserRoot, fs.ChatFilename, modifiedContent) +} + +// readChatMsgs parses content into logical blocks +// Returns slice where each element is either a header or a complete record +func readChatMsgs(content string) []string { + content = txt.NormNewLines(content) + lines := strings.Split(content, "\n") + + headerRegex := regexp.MustCompile(`^#### `) + // Block start: a `- [ ] ` / `- [x] ` checklist line (timestamp optional). + timestampRegex := regexp.MustCompile(`^- \[[ xX]\] `) + + var blocks []string + var currentBlock strings.Builder + + for _, line := range lines { + isHeader := headerRegex.MatchString(line) + isTimestamp := timestampRegex.MatchString(line) + + if isHeader { + // Save previous block if exists + if currentBlock.Len() > 0 { + blocks = append(blocks, strings.TrimSpace(currentBlock.String())) + currentBlock.Reset() + } + // DisplayName is always its own block + blocks = append(blocks, line) + } else if isTimestamp { + // Save previous block if exists + if currentBlock.Len() > 0 { + blocks = append(blocks, strings.TrimSpace(currentBlock.String())) + currentBlock.Reset() + } + // Start new block with timestamp + currentBlock.WriteString(line) + } else { + // Continue current block or start new block + if currentBlock.Len() > 0 { + currentBlock.WriteString("\n") + currentBlock.WriteString(line) + } else { + currentBlock.WriteString(line) + } + } + } + + // Add final block + if currentBlock.Len() > 0 { + blocks = append(blocks, strings.TrimSpace(currentBlock.String())) + } + + return blocks +} + +func todayHeader(timezone *time.Location) string { + nowTZ := now().In(timezone) + return fmt.Sprintf("#### %d %s, %s", nowTZ.Day(), nowTZ.Format("January"), nowTZ.Weekday()) +} + +func userLock(rootPath string) *sync.Mutex { + mu.Lock() + defer mu.Unlock() + + if userLocks == nil { + userLocks = make(map[string]*sync.Mutex) + } + if lock, exists := userLocks[rootPath]; exists { + return lock + } + + newLock := &sync.Mutex{} + userLocks[rootPath] = newLock + + return newLock +} diff --git a/server/chat_test.go b/server/chat_test.go new file mode 100644 index 0000000..4f6126e --- /dev/null +++ b/server/chat_test.go @@ -0,0 +1,755 @@ +package server + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/zakirullin/files.md/server/db" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/pkg/tg" +) + +func TestReadMessagesEmpty(t *testing.T) { + r := require.New(t) + result := readChatMsgs("") + r.Empty(result) +} + +func TestReadMessagesOnlyHeader(t *testing.T) { + r := require.New(t) + result := readChatMsgs("#### 27 June, Friday") + r.Equal([]string{"#### 27 June, Friday"}, result) +} + +func TestReadMessagesSingleRecord(t *testing.T) { + r := require.New(t) + result := readChatMsgs("- [ ] `01:01` Simple record") + r.Equal([]string{"- [ ] `01:01` Simple record"}, result) +} + +func TestReadMessagesHeaderWithRecord(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n- [ ] `01:01` Simple record" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` Simple record"}, result) +} + +func TestReadMessagesMultilineRecord(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n- [ ] `01:01` Multiline\nc\nontent" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` Multiline\nc\nontent"}, result) +} + +func TestReadMessagesMultipleRecords(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n- [ ] `01:01` First record\n- [ ] `02:02` Second record" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` First record", "- [ ] `02:02` Second record"}, result) +} + +func TestReadMessagesMultipleHeaders(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n- [ ] `01:01` First day\n#### 28 June, Saturday\n- [ ] `02:02` Second day" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` First day", "#### 28 June, Saturday", "- [ ] `02:02` Second day"}, result) +} + +func TestReadMessagesWindowsLineEndings(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\r\n- [ ] `01:01` Windows record" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` Windows record"}, result) +} + +func TestReadMessagesWithEmptyLines(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n\n- [ ] `01:01` Record with\n\nempty lines" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "- [ ] `01:01` Record with\n\nempty lines"}, result) +} + +func TestReadMessagesInvalidTimestamp(t *testing.T) { + r := require.New(t) + content := "#### 27 June, Friday\n`not timestamp` Should be continuation\n- [ ] `01:01` Real record" + result := readChatMsgs(content) + r.Equal([]string{"#### 27 June, Friday", "`not timestamp` Should be continuation", "- [ ] `01:01` Real record"}, result) +} + +func TestSaveToChatNewFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2024, 6, 27, 1, 1, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + h, err := bot.appendToChat("Test content", time.UTC) + r.NoError(err) + r.Equal(chatBlockHash("- [ ] `01:01` Test content"), h) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.Equal("#### 27 June, Thursday\n- [ ] `01:01` Test content\n", content) +} + +func TestSaveToChatExistingFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2024, 6, 27, 1, 1, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, "#### 27 June, Thursday\n`00:30` Existing content\n") + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + h, err := bot.appendToChat("New content", time.UTC) + r.NoError(err) + r.Equal(chatBlockHash("- [ ] `01:01` New content"), h) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.Equal("#### 27 June, Thursday\n`00:30` Existing content\n- [ ] `01:01` New content\n", content) +} + +func TestSaveToChatNewDay(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2024, 6, 28, 1, 1, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, "#### 27 June, Thursday\n`00:30` Yesterday content\n") + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + h, err := bot.appendToChat("Today content", time.UTC) + r.NoError(err) + r.Equal(chatBlockHash("- [ ] `01:01` Today content"), h) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.Equal("#### 27 June, Thursday\n`00:30` Yesterday content\n#### 28 June, Friday\n- [ ] `01:01` Today content\n", content) +} + +func TestSaveToChatWithImage(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2024, 6, 27, 1, 1, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + h, err := bot.appendToChat("![](image.jpg) Image description", time.UTC) + r.NoError(err) + r.Equal(chatBlockHash("- [ ] `01:01` ![](image.jpg) Image description"), h) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.Equal("#### 27 June, Thursday\n- [ ] `01:01` ![](image.jpg) Image description\n", content) +} + +func TestSaveToChatEmptyFile(t *testing.T) { + r := require.New(t) + + savedNow := now + defer func() { now = savedNow }() + now = func() time.Time { + return time.Date(2024, 6, 27, 1, 1, 0, 0, time.UTC) + } + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, "") + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + h, err := bot.appendToChat("Test content", time.UTC) + r.NoError(err) + r.Equal(chatBlockHash("- [ ] `01:01` Test content"), h) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + r.Equal("#### 27 June, Thursday\n- [ ] `01:01` Test content\n", content) +} + +//func TestSaveToChatWithTimezone(t *testing.T) { +// r := require.New(t) +// +// savedNow := now +// defer func() { now = savedNow }() +// now = func() time.Time { +// return time.Date(2024, 6, 27, 1, 1, 0, 0, time.UTC) +// } +// +// userFS, err := fs.NewFS("/", afero.NewMemMapFs()) +// r.NoError(err) +// +// bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) +// +// // Use EST timezone (UTC-5) +// est, err := time.LoadLocation("America/New_York") +// r.NoError(err) +// +// index, err := bot.saveToChat("Test content", est) +// r.NoError(err) +// r.Equal(1, index) +// +// content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) +// r.NoError(err) +// // Should use EST time which is 20:01 (8:01 PM) the previous day +// r.Contains(content, "`20:01` Test content") +//} + +// Test normal case - properly formatted content +func TestReadMessages_NormalCase(t *testing.T) { + content := `#### 1 July, Tuesday +- [ ] ` + "`15:19`" + ` Пройтись на улице +#### 2 July, Wednesday +- [ ] ` + "`10:30`" + ` Почитать книгу` + + result := readChatMsgs(content) + expected := []string{ + "#### 1 July, Tuesday", + "- [ ] `15:19` Пройтись на улице", + "#### 2 July, Wednesday", + "- [ ] `10:30` Почитать книгу", + } + + if len(result) != len(expected) { + t.Fatalf("Expected %d blocks, got %d", len(expected), len(result)) + } + + for i, block := range result { + if block != expected[i] { + t.Errorf("Block %d mismatch:\nExpected: %q\nGot: %q", i, expected[i], block) + } + } +} + +// Test message without timestamp - this could be the source of the issue +func TestReadMessages_MessageWithoutTimestamp(t *testing.T) { + content := `#### 1 July, Tuesday +- [ ] ` + "`15:19`" + ` Пройтись на улице +Провести звонок с Нео +#### 2 July, Wednesday +- [ ] ` + "`10:30`" + ` Почитать книгу` + + result := readChatMsgs(content) + + // The message without timestamp should be grouped with the previous timestamped message + expected := []string{ + "#### 1 July, Tuesday", + "- [ ] `15:19` Пройтись на улице\nПровести звонок с Нео", + "#### 2 July, Wednesday", + "- [ ] `10:30` Почитать книгу", + } + + if len(result) != len(expected) { + t.Fatalf("Expected %d blocks, got %d\nGot: %v", len(expected), len(result), result) + } + + for i, block := range result { + if block != expected[i] { + t.Errorf("Block %d mismatch:\nExpected: %q\nGot: %q", i, expected[i], block) + } + } +} + +//func TestReadMessages_MultipleMessagesWithoutTimestamps(t *testing.T) { +// content := `#### 1 July, Tuesday +//` + "`15:19`" + ` Пройтись на улице +//Провести звонок с Нео +//Купить молоко +//#### 2 July, Wednesday +//Почитать книгу +//` + "`10:30`" + ` Сходить в магазин` +// +// result := readMessages(content) +// +// // Check what happens with multiple untimestamped messages +// t.Logf("Result blocks: %v", result) +// +// // This should reveal how readMessages handles content without timestamps +// headerRegex := regexp.MustCompile(`^#### `) +// timestampRegex := regexp.MustCompile(`^` + "`" + `\d{2}:\d{2}` + "`" + ` `) +// +// for i, block := range result { +// isHeader := headerRegex.MatchString(block) +// hasTimestamp := timestampRegex.MatchString(block) +// t.Logf("Block %d: isHeader=%v, hasTimestamp=%v, content=%q", i, isHeader, hasTimestamp, block) +// +// if !isHeader && !hasTimestamp { +// t.Errorf("Found record without timestamp: %q", block) +// } +// } +//} + +//func TestReadMessages_ContentAfterHeaderWithoutTimestamp(t *testing.T) { +// content := `#### 1 July, Tuesday +//#### 2 July, Wednesday +//Почитать книгу +//` + "`10:30`" + ` Сходить в магазин` +// +// result := readMessages(content) +// +// t.Logf("Result blocks: %v", result) +// +// // This case might be critical - content right after header without timestamp +// headerRegex := regexp.MustCompile(`^#### `) +// timestampRegex := regexp.MustCompile(`^` + "`" + `\d{2}:\d{2}` + "`" + ` `) +// +// for i, block := range result { +// isHeader := headerRegex.MatchString(block) +// hasTimestamp := timestampRegex.MatchString(block) +// t.Logf("Block %d: isHeader=%v, hasTimestamp=%v, content=%q", i, isHeader, hasTimestamp, block) +// +// if !isHeader && !hasTimestamp { +// t.Errorf("Found record without timestamp: %q", block) +// } +// } +//} + +// Test multiline message formatting +func TestReadBlocks_MultilineMessage(t *testing.T) { + content := `#### 1 July, Tuesday +- [ ] ` + "`15:19`" + ` Пройтись на улице +и купить хлеб +в магазине +#### 2 July, Wednesday +- [ ] ` + "`10:30`" + ` Почитать книгу` + + result := readChatMsgs(content) + expected := []string{ + "#### 1 July, Tuesday", + "- [ ] `15:19` Пройтись на улице\nи купить хлеб\nв магазине", + "#### 2 July, Wednesday", + "- [ ] `10:30` Почитать книгу", + } + + if len(result) != len(expected) { + t.Fatalf("Expected %d blocks, got %d", len(expected), len(result)) + } + + for i, block := range result { + if block != expected[i] { + t.Errorf("Block %d mismatch:\nExpected: %q\nGot: %q", i, expected[i], block) + } + } +} + +func TestReadBlocksHasTimestamp(t *testing.T) { + content := `#### 1 July, Tuesday +- [ ] ` + "`15:19`" + ` Do some stuff +Arrange a call with Neo +#### 2 July, Wednesday +- [ ] ` + "`10:30`" + ` Read a book` + + messages := readChatMsgs(content) + + // Filter to find record messages (not headers) + headerRegex := regexp.MustCompile(`^#### `) + var recordIndices []int + var records []string + + for i, message := range messages { + if !headerRegex.MatchString(message) { + recordIndices = append(recordIndices, i) + records = append(records, message) + } + } + + t.Logf("Found %d records:", len(records)) + for i, record := range records { + t.Logf("Record %d: %q", i, record) + } + + timestampRegex := regexp.MustCompile(`^- \[[ xX]\] ` + "`" + `\d{2}:\d{2}` + "`" + ` `) + for i, record := range records { + hasTimestamp := timestampRegex.MatchString(record) + t.Logf("Record %d has timestamp: %v", i, hasTimestamp) + if !hasTimestamp { + t.Errorf("Record %d missing timestamp: %q", i, record) + } + } +} + +//func TestReadMessages_ExactIssueScenario(t *testing.T) { +// content := `#### 1 July, Tuesday +//#### 2 July, Wednesday +//Почитать книгу +//` + "`15:19`" + ` Пройтись на улице +//Провести звонок с Нео` +// +// result := readMessages(content) +// +// t.Logf("Input content:") +// t.Logf("%q", content) +// t.Logf("Parsed blocks:") +// for i, block := range result { +// t.Logf("Block %d: %q", i, block) +// } +// +// // Check for records without timestamps +// headerRegex := regexp.MustCompile(`^#### `) +// timestampRegex := regexp.MustCompile(`^` + "`" + `\d{2}:\d{2}` + "`" + ` `) +// +// for i, block := range result { +// isHeader := headerRegex.MatchString(block) +// hasTimestamp := timestampRegex.MatchString(block) +// +// if !isHeader && !hasTimestamp { +// t.Errorf("Found record without timestamp at index %d: %q", i, block) +// } +// } +//} + +// Test timestamp pattern matching +func TestTimestampPatternMatching(t *testing.T) { + timestampRegex := regexp.MustCompile(`^` + "`" + `\d{2}:\d{2}` + "`" + ` `) + + validTimestamps := []string{ + "`15:19` Пройтись на улице", + "`10:30` Почитать книгу", + "`23:59` Test message", + } + + invalidTimestamps := []string{ + "15:19 Пройтись на улице", // No backticks + "Пройтись на улице", // No timestamp + "15:19` Пройтись на улице", // Missing opening backtick + "`15:19 Пройтись на улице", // Missing closing backtick + "`5:19` Пройтись на улице", // Single digit hour + "`15:9` Пройтись на улице", // Single digit minute + } + + for _, ts := range validTimestamps { + if !timestampRegex.MatchString(ts) { + t.Errorf("Valid timestamp not matched: %q", ts) + } + } + + for _, ts := range invalidTimestamps { + if timestampRegex.MatchString(ts) { + t.Errorf("Invalid timestamp matched: %q", ts) + } + } +} + +// Test what happens when saveToChat adds content without proper formatting +func TestSaveToChat_ContentAddition(t *testing.T) { + // Mock the current time for testing + originalNow := Now + defer func() { Now = originalNow }() + + Now = func() time.Time { + return time.Date(2024, 7, 2, 15, 19, 0, 0, time.UTC) + } + + timezone := time.UTC + + // Test content formatting in saveToChat + content := "Arrange call with Neo" + timestamp := Now().In(timezone).Format("`15:04`") + expectedFormat := timestamp + " " + content + "\n" + + t.Logf("Content: %q", content) + t.Logf("Timestamp: %q", timestamp) + t.Logf("Expected format: %q", expectedFormat) + + // This should match the format from saveToChat function + if !strings.Contains(expectedFormat, "`15:19`") { + t.Errorf("Timestamp format is incorrect: %q", expectedFormat) + } +} + +// Test edge case: empty content handling +func TestReadMessages_EmptyContent(t *testing.T) { + content := "" + result := readChatMsgs(content) + + if len(result) != 0 { + t.Errorf("Expected 0 blocks for empty content, got %d: %v", len(result), result) + } +} + +// Test edge case: only headers +func TestReadMessages_OnlyHeaders(t *testing.T) { + content := `#### 1 July, Tuesday +#### 2 July, Wednesday` + + result := readChatMsgs(content) + expected := []string{ + "#### 1 July, Tuesday", + "#### 2 July, Wednesday", + } + + if len(result) != len(expected) { + t.Fatalf("Expected %d blocks, got %d", len(expected), len(result)) + } + + for i, block := range result { + if block != expected[i] { + t.Errorf("Block %d mismatch:\nExpected: %q\nGot: %q", i, expected[i], block) + } + } +} + +func TestMoveFromInboxSingleRecord(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + initialContent := `#### 27 June, Thursday +- [ ] ` + "`01:01`" + ` First record +- [ ] ` + "`02:02`" + ` Second record +#### 28 June, Friday +- [ ] ` + "`03:03`" + ` Third record` + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, initialContent) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + var callbackCalls []struct { + content string + timestamp time.Time + } + + callback := func(content string, timestamp time.Time) error { + callbackCalls = append(callbackCalls, struct { + content string + timestamp time.Time + }{content, timestamp}) + return nil + } + + err = bot.moveFromChat(callback, false, chatBlockHash("- [ ] `02:02` Second record")) + r.NoError(err) + + r.Len(callbackCalls, 1) + r.Equal("Second record", callbackCalls[0].content) + + expectedTime, _ := time.Parse("2 January 15:04", "27 June 02:02") + r.Equal(expectedTime, callbackCalls[0].timestamp) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + + expectedContent := `#### 27 June, Thursday +- [ ] ` + "`01:01`" + ` First record +#### 28 June, Friday +- [ ] ` + "`03:03`" + ` Third record` + + r.Equal(expectedContent, content) +} + +func TestMoveFromInboxMultipleRecords(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + initialContent := `#### 1 July, Monday +- [ ] ` + "`10:30`" + ` Buy groceries +milk, bread, eggs +- [ ] ` + "`11:45`" + ` Call mom +#### 2 July, Tuesday +- [ ] ` + "`09:15`" + ` Morning workout +- [ ] ` + "`14:20`" + ` Team meeting +discuss project timeline +and resource allocation` + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, initialContent) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + var callbackCalls []struct { + content string + timestamp time.Time + } + + callback := func(content string, timestamp time.Time) error { + callbackCalls = append(callbackCalls, struct { + content string + timestamp time.Time + }{content, timestamp}) + return nil + } + + err = bot.moveFromChat(callback, false, + chatBlockHash("- [ ] `10:30` Buy groceries\nmilk, bread, eggs"), + chatBlockHash("- [ ] `09:15` Morning workout"), + ) + r.NoError(err) + + r.Len(callbackCalls, 2) + + r.Equal("Buy groceries\nmilk, bread, eggs", callbackCalls[0].content) + expectedTime1, _ := time.Parse("2 January 15:04", "1 July 10:30") + r.Equal(expectedTime1, callbackCalls[0].timestamp) + + r.Equal("Morning workout", callbackCalls[1].content) + expectedTime2, _ := time.Parse("2 January 15:04", "2 July 09:15") + r.Equal(expectedTime2, callbackCalls[1].timestamp) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + + expectedContent := `#### 1 July, Monday +- [ ] ` + "`11:45`" + ` Call mom +#### 2 July, Tuesday +- [ ] ` + "`14:20`" + ` Team meeting +discuss project timeline +and resource allocation` + + r.Equal(expectedContent, content) +} + +func TestMoveFromChatCollapsedSingleRecord(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + initialContent := `#### 27 June, Thursday +- [ ] ` + "`01:01`" + ` First record +- [ ] ` + "`02:02`" + ` Second record +#### 28 June, Friday +- [ ] ` + "`03:03`" + ` Third record` + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, initialContent) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + var callbackCalls []struct { + content string + timestamp time.Time + } + + callback := func(content string, timestamp time.Time) error { + callbackCalls = append(callbackCalls, struct { + content string + timestamp time.Time + }{content, timestamp}) + return nil + } + + err = bot.moveFromChat(callback, true, chatBlockHash("- [ ] `02:02` Second record")) + r.NoError(err) + + r.Len(callbackCalls, 1) + r.Equal("Second record", callbackCalls[0].content) + + expectedTime, _ := time.Parse("2 January 15:04", "27 June 02:02") + r.Equal(expectedTime, callbackCalls[0].timestamp) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + + expectedContent := `#### 27 June, Thursday +- [ ] ` + "`01:01`" + ` First record +#### 28 June, Friday +- [ ] ` + "`03:03`" + ` Third record` + + r.Equal(expectedContent, content) +} + +func TestMoveFromChatCollapsedMultipleRecords(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + initialContent := `#### 1 July, Monday +- [ ] ` + "`10:30`" + ` Buy groceries +milk, bread, eggs +- [ ] ` + "`11:45`" + ` Call mom +#### 2 July, Tuesday +- [ ] ` + "`09:15`" + ` Morning workout +- [ ] ` + "`14:20`" + ` Team meeting +discuss project timeline +and resource allocation` + + err = userFS.Write(fs.DirUserRoot, fs.ChatFilename, initialContent) + r.NoError(err) + + bot := NewBot(-1, tg.NewFakeTG(), userFS, db.NewFakeDB(), fakeConfig()) + + // Track callback calls + var callbackCalls []struct { + content string + timestamp time.Time + } + + callback := func(content string, timestamp time.Time) error { + callbackCalls = append(callbackCalls, struct { + content string + timestamp time.Time + }{content, timestamp}) + return nil + } + + err = bot.moveFromChat(callback, true, + chatBlockHash("- [ ] `10:30` Buy groceries\nmilk, bread, eggs"), + chatBlockHash("- [ ] `09:15` Morning workout"), + ) + r.NoError(err) + + r.Len(callbackCalls, 1) + + expectedCollapsedContent := `Buy groceries +milk, bread, eggs +Morning workout` + r.Equal(expectedCollapsedContent, callbackCalls[0].content) + + expectedTime, _ := time.Parse("2 January 15:04", "1 July 10:30") + r.Equal(expectedTime, callbackCalls[0].timestamp) + + content, err := userFS.Read(fs.DirUserRoot, fs.ChatFilename) + r.NoError(err) + + expectedContent := `#### 1 July, Monday +- [ ] ` + "`11:45`" + ` Call mom +#### 2 July, Tuesday +- [ ] ` + "`14:20`" + ` Team meeting +discuss project timeline +and resource allocation` + + r.Equal(expectedContent, content) +} diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..be78e7d --- /dev/null +++ b/server/config/config.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/kelseyhightower/envconfig" +) + +// APIURL / AppURL carry the full scheme+host (e.g. "https://api.files.md"). +// Hostnames are derived from them on demand via APIHost()/AppHost(). +type Config struct { + WorkingDir string + StorageDir string `default:"./storage" envconfig:"STORAGE_DIR"` + BotAPIToken string `required:"false" envconfig:"BOT_API_TOKEN"` + ConfigFilename string `default:"config.json"` + APIURL string `default:"" envconfig:"API_URL"` + AppURL string `default:"" envconfig:"APP_URL"` + ServerCertDir string `default:"/tmp" envconfig:"CERT_DIR"` + TokensDir string `default:"/tmp" envconfig:"TOKENS_DIR"` + TokensSalt string `envconfig:"TOKENS_SALT"` + ServerLogFile string `default:"/tmp/server.log" envconfig:"LOG_FILE"` + StorageQuotaKB int64 `default:"1024" envconfig:"STORAGE_QUOTA_KB"` // 1MB + UnlimitedQuotaIDs string `envconfig:"UNLIMITED_QUOTA_IDS"` +} + +func (c Config) APIHost() string { return hostOf(c.APIURL) } +func (c Config) AppHost() string { return hostOf(c.AppURL) } + +func hostOf(rawURL string) string { + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Host +} + +var ServerCfg Config + +func LoadBotConfig() error { + if err := envconfig.Process("", &ServerCfg); err != nil { + return fmt.Errorf("can't load config: %w", err) + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("config can't get working directory: %w", err) + } + ServerCfg.WorkingDir = wd + + if !filepath.IsAbs(ServerCfg.StorageDir) { + ServerCfg.StorageDir = filepath.Join(wd, ServerCfg.StorageDir) + } + + return nil +} diff --git a/server/db/db.go b/server/db/db.go new file mode 100644 index 0000000..aff5f88 --- /dev/null +++ b/server/db/db.go @@ -0,0 +1,153 @@ +// Package db provides an in-memory database for storing user-specific temporary data. +package db + +import ( + "fmt" + "os" + "strconv" + "sync" + + "github.com/zakirullin/files.md/server/pkg/tg" +) + +// In-memory database +var ( + hashOrPathByMsgID sync.Map + inputExpectations sync.Map + recentCommands sync.Map + recentCommandsTargets sync.Map + sentPhotoMsgIDs sync.Map +) + +// DB Do we need a type at all? +type DB struct { + UserID int64 +} + +func NewDB(userID int64) *DB { + return &DB{UserID: userID} +} + +// TODO add locks +func (db *DB) LastKeyboardMsgID() (int, bool) { + msgIDStr, err := os.ReadFile(tmpFilePath(db.UserID, "msgid")) + if err != nil { + return 0, false + } + + msgID, err := strconv.Atoi(string(msgIDStr)) + if err != nil { + return 0, false + } + + return msgID, true +} + +func (db *DB) SetLastKeyboardMsgID(ID int) { + _ = os.WriteFile(tmpFilePath(db.UserID, "msgid"), []byte(strconv.Itoa(ID)), 0o644) +} + +func (db *DB) DelLastKeyboardMsgID() { + _ = os.Remove(tmpFilePath(db.UserID, "msgid")) +} + +func (db *DB) InputExpectation() *tg.Cmd { + val, ok := inputExpectations.Load(inputExpectationKey(db.UserID)) + if !ok { + return nil + } + + cmd := val.(tg.Cmd) + return &cmd +} + +func (db *DB) SetInputExpectation(cmd tg.Cmd) { + inputExpectations.Store(inputExpectationKey(db.UserID), cmd) +} + +func (db *DB) DelInputExpectation() { + inputExpectations.Delete(inputExpectationKey(db.UserID)) +} + +// HashOrPathByMsgID returns the target the bot rendered for this msgID - +// either a chat-block hash or an absolute file path. Callers distinguish +// the two by the leading "/" (paths) vs no slash (hash). +func (db *DB) HashOrPathByMsgID(msgID int) (string, bool) { + v, ok := hashOrPathByMsgID.Load(hashOrPathByMsgIDKey(db.UserID, msgID)) + if !ok { + return "", false + } + return v.(string), true +} + +func (db *DB) SetHashOrPathByMsgID(msgID int, value string) { + hashOrPathByMsgID.Store(hashOrPathByMsgIDKey(db.UserID, msgID), value) +} + +func (db *DB) RecentCommand() (string, bool) { + cmd, ok := recentCommands.Load(db.UserID) + if !ok { + return "", false + } + + return cmd.(string), true +} + +func (db *DB) SetRecentCommand(cmd string) { + recentCommands.Store(db.UserID, cmd) +} + +func (db *DB) RecentCommandParams() ([]string, bool) { + params, ok := recentCommandsTargets.Load(db.UserID) + if !ok { + return nil, false + } + + return params.([]string), true +} + +func (db *DB) SetRecentCommandParams(params []string) { + recentCommandsTargets.Store(db.UserID, params) +} + +func (db *DB) AddImgMsgID(msgID int) { + key := photoMsgIDKey(db.UserID) + if val, ok := sentPhotoMsgIDs.Load(key); ok { + ids := val.([]int) + sentPhotoMsgIDs.Store(key, append(ids, msgID)) + } else { + sentPhotoMsgIDs.Store(key, []int{msgID}) + } +} + +func (db *DB) ImgMsgID() ([]int, bool) { + key := photoMsgIDKey(db.UserID) + val, ok := sentPhotoMsgIDs.Load(key) + if !ok { + return nil, false + } + + ids, _ := val.([]int) + return ids, true +} + +func (db *DB) DelImgMsgID() { + key := photoMsgIDKey(db.UserID) + sentPhotoMsgIDs.Delete(key) +} + +func photoMsgIDKey(userID int64) string { + return fmt.Sprintf("%d:sentPhotoMsgIDs", userID) +} + +func inputExpectationKey(userID int64) string { + return fmt.Sprintf("%d:inputExpectations", userID) +} + +func hashOrPathByMsgIDKey(userID int64, msgID int) string { + return fmt.Sprintf("%d:hashOrPathByMsgID:%d", userID, msgID) +} + +func tmpFilePath(userID int64, name string) string { + return fmt.Sprintf("%s/%d.%s", os.TempDir(), userID, name) +} diff --git a/server/db/db_fake.go b/server/db/db_fake.go new file mode 100644 index 0000000..f0d9f69 --- /dev/null +++ b/server/db/db_fake.go @@ -0,0 +1,81 @@ +package db + +import ( + "github.com/zakirullin/files.md/server/pkg/tg" +) + +// FakeDB is a fake database, used for testing +// We don't have to clear it after each test +type FakeDB struct { + HashOrPathByMID string + InputExpectationCMD *tg.Cmd + LastKeyboardMID int + RecentCMD string + RecentCMDParams []string +} + +func NewFakeDB() *FakeDB { + return &FakeDB{LastKeyboardMID: -1} +} + +func (db *FakeDB) LastKeyboardMsgID() (int, bool) { + if db.LastKeyboardMID == -1 { + return 0, false + } + + return db.LastKeyboardMID, true +} + +func (db *FakeDB) SetLastKeyboardMsgID(msgID int) { + db.LastKeyboardMID = msgID +} + +func (db *FakeDB) DelLastKeyboardMsgID() { + db.LastKeyboardMID = -1 +} + +func (db *FakeDB) InputExpectation() *tg.Cmd { + return db.InputExpectationCMD +} + +func (db *FakeDB) SetInputExpectation(cmd tg.Cmd) { + db.InputExpectationCMD = &cmd +} + +func (db *FakeDB) DelInputExpectation() { + db.InputExpectationCMD = nil +} + +func (db *FakeDB) HashOrPathByMsgID(msgID int) (string, bool) { + return db.HashOrPathByMID, db.HashOrPathByMID != "" +} + +func (db *FakeDB) SetHashOrPathByMsgID(msgID int, value string) { + db.HashOrPathByMID = value +} + +func (db *FakeDB) RecentCommand() (string, bool) { + return db.RecentCMD, db.RecentCMD != "" +} + +func (db *FakeDB) SetRecentCommand(cmd string) { + db.RecentCMD = cmd +} + +func (db *FakeDB) RecentCommandParams() ([]string, bool) { + return db.RecentCMDParams, len(db.RecentCMDParams) > 0 +} + +func (db *FakeDB) SetRecentCommandParams(params []string) { + db.RecentCMDParams = params +} + +func (db *FakeDB) AddImgMsgID(msgID int) { +} + +func (db *FakeDB) ImgMsgID() ([]int, bool) { + return nil, false +} + +func (db *FakeDB) DelImgMsgID() { +} diff --git a/server/db/db_test.go b/server/db/db_test.go new file mode 100644 index 0000000..3a49c63 --- /dev/null +++ b/server/db/db_test.go @@ -0,0 +1 @@ +package db diff --git a/server/fs/fs.go b/server/fs/fs.go new file mode 100644 index 0000000..b07a0e9 --- /dev/null +++ b/server/fs/fs.go @@ -0,0 +1,655 @@ +// Package fs provides a simple interface for files manipulations. +// Bot users should have all their artefacts saved in cross-platform +// plain text files, that's why we use good old-fashioned filesystem. +// Each user should have its own isolated root folder. +// TODO maybe make ... access for all methods? So we can use both paths and segments +// Why not BasePathFS? +package fs + +import ( + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/spf13/afero" + "golang.org/x/exp/slices" + + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +var ( + NewUserFS = newUserFS + + LogRename = func(time int64, oldPath, newPath string) {} // callback to track renames + LogDelete = func(time int64, path string) {} // callback to track deletes + + ErrQuotaExceeded = errors.New("storage quota exceeded") + ErrUnsafePath = errors.New("unsafe path, possible security issue") + ErrCannotUnhash = errors.New("cannot unhash, maybe the file is missing") +) + +const ( + DirUserRoot = "/" + DirArchive = "archive" + DirMedia = "media" + DirJournal = "journal" + DirHabits = "habits" + DirInsights = "insights" + + ChatFilename = "Chat.md" + LaterFilename = "Later.md" + DoneFilename = "Done.md" + + ShopFilename = "Shop.md" + WatchFilename = "Watch.md" + ReadFilename = "Read.md" + + PomodoroTask = "Finished a break" + + MDExt = ".md" + + minSearchSimilarity = 70 +) + +// FS allows us to manipulate user files. We can use different +// backends, like an in-memory backend, which we use for testing. +// Check out types implementing afero.Fs for all available backends. +type FS struct { + rootPath string + backend afero.Fs + quotaKB int64 +} + +// File represents a file or directory +type File struct { + Name string // Filename with extension + Hash string + DisplayName string + Ctime int64 + IsMultiline bool + IsDir bool + ParentDir string +} + +// newUserFS creates a new FS for a specific user with os.FS backend. +func newUserFS(userID int64) (*FS, error) { + userAbsPath := path.Join(config.ServerCfg.StorageDir, txt.I64(userID)) + backend := afero.NewOsFs() + + quotaKB := config.ServerCfg.StorageQuotaKB + if isUnlimitedQuota(userID, config.ServerCfg.UnlimitedQuotaIDs) { + quotaKB = 0 + } + + return NewFS(userAbsPath, backend, quotaKB) +} + +func NewFS(absRootPath string, backend afero.Fs, quotaKB ...int64) (*FS, error) { + exists, err := afero.Exists(backend, absRootPath) + if err != nil { + return nil, fmt.Errorf("new fs: %w", err) + } + if !exists { + err = backend.Mkdir(absRootPath, 0o755) + if err != nil { + return nil, fmt.Errorf("new fs: %w", err) + } + } + + var q int64 + if len(quotaKB) > 0 { + q = quotaKB[0] + } + + return &FS{absRootPath, backend, q}, nil +} + +func NewFile(name, hash, displayName string, ctime int64, isMultiline, isDir bool, parentDir string) File { + return File{ + Name: name, + Hash: hash, + DisplayName: displayName, + Ctime: ctime, + IsMultiline: isMultiline, + IsDir: isDir, + ParentDir: parentDir, + } +} + +// CreateDirsIfNotExist creates specified directories for a user if they do not exist. +// If dirs are not specified, it creates default directories. +func (fs FS) CreateDirsIfNotExist(dirs ...string) error { + for _, dir := range dirs { + if dir == DirUserRoot { + continue + } + + userPath := path.Join(fs.rootPath, dir) + exists, err := afero.Exists(fs.backend, userPath) + if err != nil { + return fmt.Errorf("create default dirs: %w", err) + } + + if !exists { + err = fs.backend.Mkdir(userPath, 0o755) + if err != nil { + return fmt.Errorf("create default dirs: %w", err) + } + } + } + + return nil +} + +func (fs FS) CreateSystemDirs() error { + return fs.CreateDirsIfNotExist(DirArchive, DirMedia, DirJournal) +} + +func (fs FS) Exists(dir, filename string) (bool, error) { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return false, fmt.Errorf("exists: unsafe path '%s': %w", filepath.Join(dir, filename), ErrUnsafePath) + } + + exists, err := afero.Exists(fs.backend, filePath) + if err != nil { + return false, fmt.Errorf("exists: can't check whether the file '%s'/'%s' exists: %w", dir, filename, err) + } + + return exists, nil +} + +func (fs FS) Read(dir, filename string) (string, error) { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return "", fmt.Errorf("fs read: unsafe filePath dir: '%s', filename: '%s': %w", dir, filename, ErrUnsafePath) + } + + content, err := afero.ReadFile(fs.backend, filePath) + if err != nil { + return "", fmt.Errorf("fs read: can't read file '%s': %w", filePath, err) + } + + return string(content), nil +} + +func (fs FS) Write(dir, filename, content string) error { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return fmt.Errorf("fs write: unsafe filePath '%s': %w", filepath.Join(dir, filename), ErrUnsafePath) + } + + dirs := strings.Split(filePath, "/") + dirs = dirs[:len(dirs)-1] + pathToDir := strings.Join(dirs, "/") + if err := fs.backend.MkdirAll(pathToDir, 0o755); err != nil { + return fmt.Errorf("fs write: can't create dirs '%s': %w", pathToDir, err) + } + + // Track old size for quota accounting. + var oldSize int64 + if info, err := fs.backend.Stat(filePath); err == nil { + oldSize = info.Size() + } + + newSize := int64(len(content)) + if err := checkQuota(fs.rootPath, fs.backend, fs.quotaKB, newSize-oldSize); err != nil { + return err + } + + // Append mode for forwards? + if err := afero.WriteFile(fs.backend, filePath, []byte(content), 0o644); err != nil { + return fmt.Errorf("fs write to '%s/%s': %w", dir, filename, err) + } + + recordQuotaUsage(fs.rootPath, newSize-oldSize) + + return nil +} + +func (fs FS) MakeDir(dir string) error { + filePath, err := fs.SafePath(dir, "") + if err != nil { + return fmt.Errorf("fs make dir: unsafe filePath '%s': %w", filePath, ErrUnsafePath) + } + + err = fs.backend.Mkdir(filePath, 0o755) + if err != nil { + return fmt.Errorf("fs can't make dir: %w", err) + } + + return nil +} + +func (fs FS) Del(dir, filename string) error { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return fmt.Errorf("fs del file: unsafe filePath '%s': %w", filePath, ErrUnsafePath) + } + + var fileSize int64 + if info, err := fs.backend.Stat(filePath); err == nil { + fileSize = info.Size() + } + + err = fs.backend.Remove(filePath) + if err != nil { + return fmt.Errorf("fs file: can't remove '%s': %w", filePath, err) + } + + recordQuotaUsage(fs.rootPath, -fileSize) + + // filePath is already absolute (SafePath joins rootPath in). + LogDelete(time.Now().Unix(), filePath) + + return nil +} + +func (fs FS) Rename(oldDir, oldFilename, newDir, newFilename string) error { + oldPath, err := fs.SafePath(oldDir, oldFilename) + if err != nil { + return fmt.Errorf("fs can't rename from '%s': %w", oldPath, ErrUnsafePath) + } + + newPath, err := fs.SafePath(newDir, newFilename) + if err != nil { + return fmt.Errorf("fs can't rename to '%s': %w", newPath, ErrUnsafePath) + } + + err = fs.CreateDirsIfNotExist(oldDir, newDir) + if err != nil { + return fmt.Errorf("fs can't rename: %w", err) + } + + err = fs.backend.Rename(oldPath, newPath) + if err != nil { + return fmt.Errorf("can't rename from '%s' to '%s': %w", oldPath, newPath, err) + } + + // Log renaming. + ctime, err := fs.Ctime(newDir, newFilename) + // Nothing terrible will happen if we don't log a rename. The client would just have duplicate files. + if err == nil { + absOldPath := path.Join(fs.rootPath, oldDir, oldFilename) + absNewPath := path.Join(fs.rootPath, newDir, newFilename) + LogRename(ctime, absOldPath, absNewPath) + } + + return nil +} + +func (fs FS) Unhash(dir, filenameHash string) (string, error) { + if dir == DirUserRoot && filenameHash == DirUserRoot { + return DirUserRoot, nil + } + + filenames, err := fs.FilesAndDirs(dir) + if err != nil { + return "", fmt.Errorf("can't unhash: %w", err) + } + for _, file := range filenames { + if strings.HasPrefix(fs.md5(file.Name), filenameHash) { + return file.Name, nil + } + } + + // Fallback, treat hash as filename + for _, file := range filenames { + if strings.HasPrefix(file.Name, filenameHash) { + return file.Name, nil + } + } + + return "", fmt.Errorf("can't unhash '%s' in '%s': %w", filenameHash, dir, ErrCannotUnhash) +} + +func (fs FS) FilesAndDirs(dir string) ([]File, error) { + userPath, err := fs.SafePath(dir, "") + if err != nil { + return nil, fmt.Errorf("can't get files for '%s': %w", path.Join(fs.rootPath, dir), ErrUnsafePath) + } + + entries, err := afero.ReadDir(fs.backend, userPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // Create folders only on write-actions + return nil, nil + } + + return nil, fmt.Errorf("can't get files for '%s': %w", path.Join(fs.rootPath, dir), err) + } + + var files []File + ignoredFiles := []string{".", "..", ".obsidian", ".gitignore", ".DS_Store", ".git"} + for _, entry := range entries { + if slices.Contains(ignoredFiles, entry.Name()) { + continue + } + + file := NewFile( + entry.Name(), + Hash(entry.Name()), + DisplayName(entry.Name()), + Ctime(entry), + entry.Size() > 0, + entry.IsDir(), + dir, + ) + files = append(files, file) + } + + return files, nil +} + +func (fs FS) Dirs() ([]File, error) { + files, err := fs.FilesAndDirs(DirUserRoot) + if err != nil { + return nil, fmt.Errorf("can't get dirs: %w", err) + } + + var dirs []File + for _, file := range files { + filePath, err := fs.SafePath(DirUserRoot, file.Name) + if err != nil { + return nil, fmt.Errorf("can't get dirs: unsafe path '%s': %w", filePath, ErrUnsafePath) + } + + isDir, err := afero.IsDir(fs.backend, filePath) + if err != nil { + return nil, fmt.Errorf("can't get dirs: %w", err) + } + if !isDir { + continue + } + + dirs = append(dirs, file) + } + + return dirs, nil +} + +func (fs FS) IsMultiline(dir, filename string) (bool, error) { + content, err := fs.Read(dir, filename) + if err != nil { + return false, fmt.Errorf("can't check for multiline: %w", err) + } + content = strings.TrimSpace(content) + + return len(content) > 0, nil +} + +func (fs FS) md5(filename string) string { + hash := md5.Sum([]byte(filename)) + return hex.EncodeToString(hash[:])[:11] +} + +func Filename(header string) string { + return txt.Ucfirst(header) + MDExt +} + +func IsChecklistItem(filename string) bool { + validChecklistItem := regexp.MustCompile(`^-.*?-(.+)`) + + return validChecklistItem.MatchString(filename) +} + +// SearchFilesByName performs search among all user .md files +// Allowed query formats: +// "directory" - return all notes from directories prefixed by this directory +// "directory note_name" - search for this note_name in all matching directories +// "note_name" - search for this note_name across all directories +// "" - return all the notes +func (fs FS) SearchFilesByName(query string) ([]File, error) { + query = strings.ToLower(strings.TrimSpace(query)) + // Check for directory traversal attack + if strings.Contains(query, "/") { + return nil, fmt.Errorf("search notes: unsafe query '%s': %w", query, ErrUnsafePath) + } + + var supposedDir, search string + dirExists, err := fs.Exists(DirUserRoot, query) + if err != nil { + return nil, fmt.Errorf("search notes: %w", err) + } + if dirExists { + supposedDir = query + } else { + parts := strings.SplitN(query, " ", 2) + supposedDir = parts[0] + if len(parts) > 1 { + search = strings.TrimSpace(parts[1]) + } + } + + rootPath, err := fs.SafePath(DirUserRoot, "") + if err != nil { + return nil, fmt.Errorf("search notes: %w", err) + } + + // Walk the whole tree once - up to 10 levels deep - and collect .md + // files. Filtering by dir prefix happens after the walk. + var notes []File + _ = afero.Walk(fs.backend, rootPath, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + base := filepath.Base(p) + if strings.HasPrefix(base, ".") { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info.IsDir() { + rel, _ := filepath.Rel(rootPath, p) + if rel != "." && strings.Count(rel, string(filepath.Separator)) >= 10 { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(base) != MDExt { + return nil + } + relativeToUserRootPath, _ := filepath.Rel(rootPath, filepath.Dir(p)) + if relativeToUserRootPath == "." || relativeToUserRootPath == "" { + relativeToUserRootPath = DirUserRoot + } + notes = append(notes, NewFile( + base, Hash(base), DisplayName(base), + Ctime(info), info.Size() > 0, false, relativeToUserRootPath, + )) + return nil + }) + notes = OnlyUserMDFiles(notes) + + // Restrict to the matching top-level dir if the user typed one. + if supposedDir != "" { + var pruned []File + for _, n := range notes { + top := strings.SplitN(n.ParentDir, "/", 2)[0] + if top == DirUserRoot { + top = "" + } + if strings.HasPrefix(top, supposedDir) { + pruned = append(pruned, n) + } + } + if len(pruned) > 0 { + notes = pruned + } else { + // No dir matched - fall back to a name search across all notes. + search = query + } + } + notes = SortByCtimeDesc(notes) + + var matchedNotes []File + for _, note := range notes { + isWildcard := len(search) == 0 + isSubstring := strings.Contains(strings.ToLower(note.DisplayName), search) + isSimilar := txt.Similar(strings.ToLower(note.DisplayName), search) > minSearchSimilarity + if isWildcard || isSubstring || isSimilar { + matchedNotes = append(matchedNotes, note) + } + } + + return matchedNotes, nil +} + +// TODO check if safe +// Touch updates an existing file's access and modification times. +// If there's no such file it creates an empty file. +func (fs FS) Touch(dir, filename string) error { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return fmt.Errorf("touch: unsafe path '%s': %w", filePath, ErrUnsafePath) + } + + exists, err := fs.Exists(dir, filename) + if err != nil { + return fmt.Errorf("touch: %w", err) + } + + if exists { + err = fs.backend.Chtimes(filePath, time.Now(), time.Now()) + if err != nil { + return fmt.Errorf("touch: can't update file's ctime: %w", err) + } + return nil + } + err = fs.Write(dir, filename, "") + if err != nil { + return fmt.Errorf("touch: can't create empty file: %w", err) + } + return nil +} + +// Ctime returns the change time of a file as Unix timestamp in milliseconds. +// It updates on file creation, modification, metadata changes, moving to a different dir, renames. +func (fs FS) Ctime(dir, filename string) (int64, error) { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return 0, fmt.Errorf("fs file: unsafe filePath '%s': %w", filePath, ErrUnsafePath) + } + + info, err := fs.backend.Stat(filePath) + if err != nil { + return 0, fmt.Errorf("fs file: can't stat file '%s': %w", filePath, err) + } + + return Ctime(info), nil +} + +// Mtime returns the modification time of a file as Unix timestamp in milliseconds. +// It only updates on file modification. +func (fs FS) Mtime(dir, filename string) (int64, error) { + filePath, err := fs.SafePath(dir, filename) + if err != nil { + return 0, fmt.Errorf("fs mtime: unsafe filePath '%s': %w", filePath, ErrUnsafePath) + } + + info, err := fs.backend.Stat(filePath) + if err != nil { + return 0, fmt.Errorf("fs mtime: can't stat file '%s': %w", filePath, err) + } + + return Mtime(info), nil +} + +// Mtimes recursively scans a directory and returns the mtime +// for all files with specified extension as Unix timestamps. +// Returns [relPath] => ctime +// TODO add tests +func (fs FS) Mtimes(root string, extensions ...string) (map[string]int64, error) { + rootPath, err := fs.SafePath(root, "") + if err != nil { + return nil, fmt.Errorf("fs mtimes: unsafe rootPath '%s': %w", rootPath, ErrUnsafePath) + } + + mtimes := make(map[string]int64) + err = afero.Walk(fs.backend, rootPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + base := filepath.Base(path) + // Skip hidden files. + if strings.HasPrefix(base, ".") && path != rootPath { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if info.IsDir() { + return nil + } + + // Only process specified file extension. + if len(extensions) > 0 { + ext := filepath.Ext(path) + if !slices.Contains(extensions, ext) { + return nil + } + } + + // TODO what if a file inside folder? + relPath, err := filepath.Rel(rootPath, path) + if err != nil { + return nil + } + + if relPath == "" { + relPath = "." + } + + mtimes[relPath] = Mtime(info) + + return nil + }) + + if err != nil { + return nil, err + } + + return mtimes, nil +} + +// SafePath returns safe path to a file or directory, error if the path is unsafe. +// Sanitize Early, call SanitizeFilename +// as soon as you get on dir and filename from user input +// TODO test all FS' public the methods for unsafePath traversal +// TODO after you cover everything with the tests, we may remove this method +// because we build our own paths (???) +// TODO release remove error? +// isSafe doesn't eval symlinks, so an attacker can create a symlink to a file +// outside the rootPath. If we use filepath.EvalSymlinks to expand symlinks and +// check the real path for safety - we are still prone to TOCTOU (time-of-check to time-of-use) +// attacks due to the race condition. The only real way to prevent this is to disallow symlinks +// at the OS level. We can do this by mounting a folder with nosymfollow flag, see README.md. +func (fs FS) SafePath(dir, filename string) (string, error) { + var relativePath string + if dir == "/" { + if filename == "" { + // Just the root directory + return fs.rootPath, nil + } + relativePath = filename + } else { + relativePath = filepath.Join(dir, filename) + } + + if !filepath.IsLocal(relativePath) { + return "", ErrUnsafePath + } + + return filepath.Join(fs.rootPath, relativePath), nil +} diff --git a/server/fs/fs_darwin.go b/server/fs/fs_darwin.go new file mode 100644 index 0000000..ecfcfc1 --- /dev/null +++ b/server/fs/fs_darwin.go @@ -0,0 +1,20 @@ +//go:build darwin + +package fs + +import ( + "os" + "syscall" +) + +var Ctime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Stat_t) + + return (stat.Ctimespec.Sec*1_000_000_000 + stat.Ctimespec.Nsec) / 1000 // Look for CONFIG_HZ in README.md +} + +var Mtime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Stat_t) + + return (stat.Mtimespec.Sec*1_000_000_000 + stat.Mtimespec.Nsec) / 1000 // Look for CONFIG_HZ in README.md +} diff --git a/server/fs/fs_func.go b/server/fs/fs_func.go new file mode 100644 index 0000000..fbc4f75 --- /dev/null +++ b/server/fs/fs_func.go @@ -0,0 +1,225 @@ +package fs + +import ( + "crypto/md5" + "encoding/hex" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +// ForbiddenChars hold replacements for characters +// not allowed in some envs like Windows, PWA apps. +// Under Linux and other Unix-related systems, +// there are only two characters that cannot +// appear in the name of a file or directory, +// and those are NUL '\0' and slash '/'. +var ForbiddenChars = map[string]string{ + "<": "<", + ">": ">", + ":": "꞉", + "\"": "″", + "|": "⼁", + "\\": "\", + "?": "?", + "*": "﹡", + // Forbidden on Unix. + "\x00": "", + "/": "/", +} + +func SanitizeFilename(filename string) string { + for forbidden, safe := range ForbiddenChars { + filename = strings.ReplaceAll(filename, forbidden, safe) + } + + return filename +} + +func UnsanitizeFilename(filename string) string { + for forbidden, safe := range ForbiddenChars { + if safe == "" { + continue + } + + filename = strings.ReplaceAll(filename, safe, forbidden) + } + + return filename +} + +func DisplayName(filename string) string { + return txt.Ucfirst(strings.TrimSuffix(strings.TrimSpace(filename), MDExt)) +} + +func Hash(filename string) string { + hash := md5.Sum([]byte(filename)) + return hex.EncodeToString(hash[:])[:11] +} + +// ShortHash returns a short hash of the filename +// Telegram only allows 64 bytes in callback_data, +// so if we have 3 params we should use shortHash. +// More collisions are possible, but it's a trade-off. +func ShortHash(filename string) string { + hash := md5.Sum([]byte(filename)) + return hex.EncodeToString(hash[:])[:5] +} + +func ExcludeChecklists(dirs []File) []File { + var newDirs []File + for _, dir := range dirs { + isChecklist := strings.HasPrefix(dir.Name, "_") && strings.HasSuffix(dir.Name, "_") + if isChecklist { + continue + } + + newDirs = append(newDirs, dir) + } + + return newDirs +} + +func ExcludeSystemDirs(dirs []File) []File { + var newDirs []File + for _, dir := range dirs { + if slices.Contains([]string{DirMedia, DirArchive, DirJournal, DirInsights, "img"}, dir.Name) { + continue + } + + newDirs = append(newDirs, dir) + } + + return newDirs +} + +func ExcludeConfig(files []File) []File { + var newFiles []File + for _, file := range files { + if file.Name == config.ServerCfg.ConfigFilename && file.ParentDir == DirUserRoot { + continue + } + + newFiles = append(newFiles, file) + } + + return newFiles +} + +func OnlyNoteDirs(dirs []File) []File { + return ExcludeSystemDirs(ExcludeChecklists(dirs)) +} + +func OnlyChecklists(dirs []File) []File { + entries := OnlyFiles(dirs) + + var checklists []File + for _, entry := range entries { + if filepath.Ext(entry.Name) != MDExt { + continue + } + filename := strings.TrimSuffix(entry.Name, MDExt) + hasChecklistPostfix := strings.HasSuffix(filename, "_") + if hasChecklistPostfix || slices.Contains([]string{ + WatchFilename, + ReadFilename, + ShopFilename, + }, entry.Name) { + checklists = append(checklists, entry) + } + } + + return checklists +} + +func OnlyUserMDFiles(entries []File) []File { + systemFiles := []string{ + ChatFilename, + LaterFilename, + DoneFilename, + ChatFilename, + ShopFilename, + WatchFilename, + ReadFilename, + } + + var files []File + for _, file := range entries { + if file.IsDir { + continue + } + if filepath.Ext(file.Name) != MDExt { + continue + } + if slices.Contains(systemFiles, file.Name) { + continue + } + files = append(files, file) + } + return files +} + +func OnlyFiles(entries []File) []File { + var files []File + for _, file := range entries { + if file.IsDir { + continue + } + + files = append(files, file) + } + + return files +} + +func OnlyDirs(entries []File) []File { + var dirs []File + for _, file := range entries { + if !file.IsDir { + continue + } + + dirs = append(dirs, file) + } + + return dirs +} + +// OnlyUserDirs returns only directories that look like user IDs +func OnlyUserDirs(entries []File) []File { + var dirs []File + for _, file := range entries { + if !file.IsDir { + continue + } + if _, err := strconv.Atoi(file.Name); err != nil { + continue + } + + dirs = append(dirs, file) + } + + return dirs +} + +func OnlyFilenames(entries []File) []string { + var filenames []string + for _, entry := range entries { + filenames = append(filenames, entry.Name) + } + + return filenames +} + +func SortByCtimeDesc(entries []File) []File { + sort.Slice(entries, func(i, j int) bool { + return entries[i].Ctime > entries[j].Ctime + }) + + return entries +} diff --git a/server/fs/fs_linux.go b/server/fs/fs_linux.go new file mode 100644 index 0000000..6583ba8 --- /dev/null +++ b/server/fs/fs_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package fs + +import ( + "os" + "syscall" +) + +// Ctime returns the creation time of a file in microseconds since epoch. +var Ctime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Stat_t) + + return (stat.Ctim.Sec*1_000_000_000 + stat.Ctim.Nsec) / 1000 // Look for CONFIG_HZ in README.md +} + +var Mtime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Stat_t) + + return (stat.Mtim.Sec*1_000_000_000 + stat.Mtim.Nsec) / 1000 // Look for CONFIG_HZ in README.md +} diff --git a/server/fs/fs_test.go b/server/fs/fs_test.go new file mode 100644 index 0000000..133a408 --- /dev/null +++ b/server/fs/fs_test.go @@ -0,0 +1,932 @@ +package fs + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func init() { + Ctime = func(fi os.FileInfo) int64 { + return 0 + } + Mtime = func(fi os.FileInfo) int64 { return 0 } +} + +func TestIsChecklistItem(t *testing.T) { + r := require.New(t) + + r.False(IsChecklistItem("-checklist-")) + r.True(IsChecklistItem("-checklist-item")) +} + +func TestDisplayName(t *testing.T) { + r := require.New(t) + + title := DisplayName("filename") + r.Equal("Filename", title) +} + +func TestDisplayNameWithSpace(t *testing.T) { + r := require.New(t) + + title := DisplayName(" filename ") + r.Equal("Filename", title) +} + +func TestMD5(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + res := fs.md5("First task.md") + + r.Equal("0824149b387", res) +} + +func TestExcludeChecklists(t *testing.T) { + r := require.New(t) + + noChecklists := ExcludeChecklists([]File{{Name: "not-a-checklist"}, {Name: "_checklist_"}}) + + r.Equal([]File{{Name: "not-a-checklist"}}, noChecklists) +} + +func TestExcludeSystemDirs(t *testing.T) { + r := require.New(t) + + noChecklists := ExcludeSystemDirs([]File{{Name: "not-a-system-dir"}, {Name: "media"}, {Name: "archive"}, {Name: "journal"}}) + + r.Equal([]File{{Name: "not-a-system-dir"}}, noChecklists) +} + +func TestIsMultiline(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "First task.md", "") + r.NoError(err) + + isMultiline, err := fs.IsMultiline("today", "First task.md") + r.NoError(err) + r.False(isMultiline) + + err = fs.Write("today", "Second task.md", "c") + r.NoError(err) + + isMultiline, err = fs.IsMultiline("today", "Second task.md") + r.NoError(err) + r.True(isMultiline) + + err = fs.Write("today", "Third task.md", " \n ") + r.NoError(err) + + isMultiline, err = fs.IsMultiline("today", "Third task.md") + r.NoError(err) + r.False(isMultiline) +} + +func TestGetFilesInDir(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "First task.md", "") + r.NoError(err) + + files, err := fs.FilesAndDirs("today") + r.NoError(err) + r.Len(files, 1) + r.Equal("First task.md", files[0].Name) +} + +func TestCreateBaseDirs(t *testing.T) { + r := require.New(t) + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + r.NoError(fs.CreateSystemDirs()) + + err = fs.CreateSystemDirs() + r.NoError(err) + + dirs, err := fs.FilesAndDirs("/") + r.NoError(err) + dirs = OnlyDirs(dirs) + dirNames := OnlyFilenames(dirs) + + r.ElementsMatch([]string{"archive", "media", "journal"}, dirNames) +} + +func TestSortByCtimeDesc(t *testing.T) { + r := require.New(t) + + saved := Ctime + defer func() { + Ctime = saved + }() + Ctime = func(fi os.FileInfo) int64 { + if fi.Name() == "b.md" { + return 1 + } + + return 2 + } + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "b.md", "") + r.NoError(err) + + err = fs.Write("today", "a.md", "") + r.NoError(err) + + entries, err := fs.FilesAndDirs("today") + r.NoError(err) + + r.Equal([]string{"a.md", "b.md"}, OnlyFilenames(SortByCtimeDesc(entries))) +} + +func TestExcludeEverythingButUserDirs(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("", "a.md", "") + r.NoError(err) + + err = fs.MakeDir("dir") + r.NoError(err) + + entries, err := fs.FilesAndDirs("/") + r.NoError(err) + + dirs := OnlyDirs(ExcludeSystemDirs(entries)) + r.Len(dirs, 1) + r.Equal("dir", dirs[0].Name) +} + +func TestOnlyFiles(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("", "a.md", "") + r.NoError(err) + + err = fs.MakeDir("dir") + r.NoError(err) + + entries, err := fs.FilesAndDirs("/") + r.NoError(err) + + dirs := OnlyUserMDFiles(entries) + r.Len(dirs, 1) + r.Equal("a.md", dirs[0].Name) +} + +func TestOnlyChecklists(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + r.NoError(fs.Write("today", "a.md", "")) + r.NoError(fs.Write("/", "list_.md", "")) + // A non-markdown file whose name happens to end in "_" must NOT be + // treated as a checklist. + r.NoError(fs.Write("/", "Molchanov_.mobi", "")) + + entries, err := fs.FilesAndDirs("/") + r.NoError(err) + + dirs := OnlyChecklists(entries) + r.Len(dirs, 1) + r.Equal("list_.md", dirs[0].Name) +} + +func TestFSTouchNew(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + exists, err := fs.Exists("today", "a.md") + r.NoError(err) + r.False(exists) + + err = fs.Touch("today", "a.md") + r.NoError(err) + + exists, err = fs.Exists("today", "a.md") + r.NoError(err) + r.True(exists) +} + +func TestFSTouchExisting(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "a.md", "A") + r.NoError(err) + + err = fs.Touch("today", "a.md") + r.NoError(err) + + content, err := fs.Read("today", "a.md") + r.NoError(err) + r.Equal("A", content) +} + +func TestFSGetAllNotesInMatchingDir(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("brain", "a.md") + r.NoError(err) + err = fs.Touch("today", "b.md") + r.NoError(err) + err = fs.Touch("non-matching-dir", "c.md") + r.NoError(err) + + notes, err := fs.SearchFilesByName("BRAIN") + r.NoError(err) + r.Len(notes, 1) + r.Equal("a.md", notes[0].Name) +} + +func TestFSGetAllMatchingNotesInMatchingDir(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("brain", "a.md") + + r.NoError(err) + err = fs.Touch("brain", "b.md") + r.NoError(err) + err = fs.Touch("today", "c.md") + r.NoError(err) + + notes, err := fs.SearchFilesByName("BRAIN A") + r.NoError(err) + r.Len(notes, 1) + r.Equal("a.md", notes[0].Name) +} + +func TestFSGetAllNotesInAllMatchingDirs(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("brain", "a.md") + r.NoError(err) + err = fs.Touch("brain", "b.md") + r.NoError(err) + err = fs.Touch("today", "c.md") + r.NoError(err) + + notes, err := fs.SearchFilesByName("brain") + r.NoError(err) + r.Len(notes, 2) + + var noteFilenames []string + for _, note := range notes { + noteFilenames = append(noteFilenames, note.Name) + } + + r.ElementsMatch([]string{"a.md", "b.md"}, noteFilenames) +} + +func TestFSGetAllMatchingNotesInAllMatchingDirs(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("brain", "a.md") + r.NoError(err) + err = fs.Touch("brain", "ab.md") + r.NoError(err) + err = fs.Touch("brain", "b.md") + r.NoError(err) + err = fs.Touch("today", "c.md") + r.NoError(err) + + notes, err := fs.SearchFilesByName("brain a") + r.NoError(err) + r.Len(notes, 2) + + var noteFilenames []string + for _, note := range notes { + noteFilenames = append(noteFilenames, note.Name) + } + + r.ElementsMatch([]string{"a.md", "ab.md"}, noteFilenames) +} + +func TestFSGetAllNotesInAllDirsForEmptyQuery(t *testing.T) { + r := require.New(t) + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("brain", "a.md") + r.NoError(err) + err = fs.Touch("b", "b.md") + r.NoError(err) + + notes, err := fs.SearchFilesByName("") + r.NoError(err) + r.Len(notes, 2) + + var noteFilenames []string + for _, note := range notes { + noteFilenames = append(noteFilenames, note.Name) + } + + r.ElementsMatch([]string{"a.md", "b.md"}, noteFilenames) +} + +func TestFSPathTraversalAttack(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/abc", afero.NewMemMapFs()) + fs.rootPath = "/abc" + + path, err := fs.SafePath("../root/.ssh/", "authorized_keys") + r.Error(err) + r.Equal("", path) + + path, err = fs.SafePath("note", "../root/.ssh/authorized_keys") + r.NoError(err) + r.Equal("/abc/root/.ssh/authorized_keys", path) + + path, err = fs.SafePath("note", "../../root/.ssh/authorized_keys") + r.Error(err) + r.Empty(path) +} + +func TestFSOnlyUserDirs(t *testing.T) { + r := require.New(t) + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = fs.MakeDir("str") + r.NoError(err) + + err = fs.MakeDir("123") + r.NoError(err) + + err = fs.MakeDir("123.56") + r.NoError(err) + + dirs, _ := fs.FilesAndDirs("/") + userDirs := OnlyUserDirs(dirs) + + r.Len(userDirs, 1) + r.Equal("123", userDirs[0].Name) +} + +func TestIsSafeWrongRoot(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/a", afero.NewMemMapFs()) + p, err := fs.SafePath("b", "") + r.NoError(err) + r.Equal("/a/b", p) + +} + +func TestIsSafePathTraversalAttack(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/a", afero.NewMemMapFs()) + p, err := fs.SafePath("a/../b", "") + r.NoError(err) + r.Equal("/a/b", p) + + p, err = fs.SafePath("/a/../../b", "") + r.Error(err) + r.Empty(p) + + p, err = fs.SafePath("./a/../b", "") + r.NoError(err) + r.Equal("/a/b", p) + + p, err = fs.SafePath("./a/../../b", "") + r.Error(err) + r.Empty(p) +} + +func TestSafePath(t *testing.T) { + r := require.New(t) + + memFS := afero.NewMemMapFs() + memFS.Mkdir("/app-secret", 0o755) + + fs, _ := NewFS("/app", memFS) + + p, err := fs.SafePath("subdir", "file") + r.NoError(err) + r.Equal("/app/subdir/file", p) + + p, err = fs.SafePath("subdir", "../file") + r.NoError(err) + r.Equal("/app/file", p) + + p, err = fs.SafePath("../app-secret", "file") + r.Error(err) + r.Equal("", p) + +} + +func TestIsSafePathTraversalAttackWithRelativePaths(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS(".", afero.NewMemMapFs()) + p, err := fs.SafePath("./a/../b", "") + r.NoError(err) + r.Equal("b", p) + + p, err = fs.SafePath("./a/../../b", "") + r.Error(err) + r.Empty(p) +} + +func TestUnhashRootDirectory(t *testing.T) { + r := require.New(t) + + fs, err := NewFS(".", afero.NewMemMapFs()) + r.NoError(err) + // TODO is it used at all? What a strange behaviour? + unhashed, err := fs.Unhash("/", "/") + r.NoError(err) + + r.Equal("/", unhashed) +} + +func TestSanitizeFilename(t *testing.T) { + r := require.New(t) + + r.Equal("ab", SanitizeFilename("a\x00b")) + r.Equal("a/b", SanitizeFilename("a/b")) + r.Equal("a\b", SanitizeFilename("a\\b")) + r.Equal("a/b\", SanitizeFilename("\x00a\x00/b\\")) +} + +func TestUnsanitizeFilename(t *testing.T) { + r := require.New(t) + + r.Equal("a/b", UnsanitizeFilename("a/b")) + r.Equal("a\\b", UnsanitizeFilename("a\b")) + r.Equal("a/b\\", UnsanitizeFilename("a/b\")) +} + +func TestExists(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "First task.md", "") + r.NoError(err) + + exists, err := fs.Exists("today", "First task.md") + r.NoError(err) + r.True(exists) +} + +func TestExistsRoot(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "First task.md", "") + r.NoError(err) + + exists, err := fs.Exists("/", "") + r.NoError(err) + r.True(exists) +} + +func TestWriteAndReadFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "test.md", "Test content") + r.NoError(err) + + content, err := fs.Read("today", "test.md") + r.NoError(err) + r.Equal("Test content", content) +} + +func TestWriteUnsafePath(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/1", afero.NewMemMapFs()) + err := fs.Write("../unsafe", "test.md", "Test content") + r.Error(err) + r.Contains(err.Error(), "unsafe filePath") +} + +func TestReadNonExistentFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + content, err := fs.Read("today", "nonexistent.md") + r.Error(err) + r.Equal("", content) +} + +func TestDelFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "delete.md", "To be deleted") + r.NoError(err) + + err = fs.Del("today", "delete.md") + r.NoError(err) + + exists, err := fs.Exists("today", "delete.md") + r.NoError(err) + r.False(exists) +} + +func TestDelNonExistentFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Del("today", "nonexistent.md") + r.Error(err) + r.Contains(err.Error(), "can't remove") +} + +func TestRenameFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Write("today", "oldname.md", "Old content") + r.NoError(err) + + err = fs.Rename("today", "oldname.md", "today", "newname.md") + r.NoError(err) + + exists, err := fs.Exists("today", "newname.md") + r.NoError(err) + r.True(exists) + + content, err := fs.Read("today", "newname.md") + r.NoError(err) + r.Equal("Old content", content) +} + +func TestRenameUnsafePath(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/1", afero.NewMemMapFs()) + err := fs.Write("today", "oldname.md", "Old content") + r.NoError(err) + + err = fs.Rename("../unsafe", "oldname.md", "today", "newname.md") + r.Error(err) + r.Contains(err.Error(), "unsafe path") +} + +func TestMakeDir(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.MakeDir("newdir") + r.NoError(err) + + exists, err := fs.Exists("newdir", "") + r.NoError(err) + r.True(exists) +} + +func TestMakeDirUnsafePath(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/1", afero.NewMemMapFs()) + err := fs.MakeDir("../unsafe") + r.Error(err) + r.Contains(err.Error(), "unsafe path") +} + +func TestUnsafePathSanitization(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/1", afero.NewMemMapFs()) + + p, err := fs.SafePath("../", "test.md") + r.Error(err) + r.Empty(p) + + p, err = fs.SafePath("safe", "../unsafe.md") + r.NoError(err) + r.Equal("/1/unsafe.md", p) +} + +func TestUnhash(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("today", "hashedfile.md") + r.NoError(err) + + hash := fs.md5("hashedfile.md") + unhashed, err := fs.Unhash("today", hash) + r.NoError(err) + r.Equal("hashedfile.md", unhashed) +} + +func TestUnhashNonExistentFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + _, err := fs.Unhash("today", "nonexistenthash") + r.Error(err) + r.Contains(err.Error(), "can't unhash") +} + +func TestSanitizeAndUnsanitizeFilename(t *testing.T) { + r := require.New(t) + + sanitized := SanitizeFilename("test/file:name\\with/special\\chars") + r.Equal("test/file꞉name\with/special\chars", sanitized) + + unsanitized := UnsanitizeFilename(sanitized) + r.Equal("test/file:name\\with/special\\chars", unsanitized) +} + +func TestFilesAndDirs(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.MakeDir("dir1") + r.NoError(err) + + err = fs.Write("dir1", "file1.md", "File content") + r.NoError(err) + + files, err := fs.FilesAndDirs("dir1") + r.NoError(err) + r.Len(files, 1) + r.Equal("file1.md", files[0].Name) +} + +func TestDirs(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.MakeDir("dir1") + r.NoError(err) + + err = fs.Write("dir1", "file1.md", "File content") + r.NoError(err) + + dirs, err := fs.Dirs() + r.NoError(err) + r.Len(dirs, 1) + r.Equal("dir1", dirs[0].Name) +} + +func TestTouchFile(t *testing.T) { + r := require.New(t) + + fs, _ := NewFS("/", afero.NewMemMapFs()) + err := fs.Touch("today", "touchfile.md") + r.NoError(err) + + exists, err := fs.Exists("today", "touchfile.md") + r.NoError(err) + r.True(exists) +} + +func FuzzWrite(f *testing.F) { + f.Add("valid_dir", "valid_file.txt", "This is valid content") + f.Add("valid_dir", "../../unsafe_file.txt", "Unsafe path content") + f.Add("valid_dir/subdir", "valid_file.md", "Nested content") + f.Add("invalid<>|*?dir", "invalid_file.txt", "Invalid dir") + f.Add("valid_dir", "file_with_emoji_🀀.txt", "Unicode content") + f.Add("../", "file.md", "content") + f.Add("../../", "file.md", "content") + f.Add("../../../", "file.md", "content") + f.Add("dir", "../file.md", "content") + f.Add("dir", "../../file.md", "content") + f.Add("dir", "../../../file.md", "content") + + f.Fuzz(func(t *testing.T, dir, filename, content string) { + filename = filename + ".md" + + r := require.New(t) + + fs := afero.NewMemMapFs() + _ = fs.Mkdir("/user", 0o755) + + userFS, err := NewFS("/user", fs) + r.NoError(err) + + err = userFS.Write(dir, filename, content) + + unsafePath := path.Join("/user/", dir, filename) + otherUserDir := !strings.HasPrefix(unsafePath, "/user/") + if unsafePath == "/user" { + otherUserDir = false + } + if otherUserDir || strings.Contains(unsafePath, "../") { + if !errors.Is(err, ErrUnsafePath) { + t.Errorf("Expected unsafe path error for dir: '%s', filename: '%s', calculated path: '%s', got: '%v'", dir, filename, unsafePath, err) + } + return + } + + r.NoError(err, "Unexpected error for valid inputs dir: '%s', filename: '%s'", dir, filename) + + filePath, err := userFS.SafePath(dir, filename) + r.NoError(err) + + actualContent, readErr := afero.ReadFile(userFS.backend, filePath) + r.NoError(readErr, "Error reading file: %s", filePath) + r.Equal(content, string(actualContent), "Content mismatch for file: %s", filePath) + + // Check that a file is indeed created in user fs, and not outside + files, err := userFS.FilesAndDirs("/") + r.NoError(err, "Unexpected error for valid inputs dir: '%s', filename: '%s', calculated path: '%s", dir, filename, unsafePath) + r.Len(files, 1, "File has been written outside of user dir. Provided dir: '%s', filename: '%s', unsafe path: '%s", dir, filename, filePath) + }) +} + +func TestCtimes(t *testing.T) { + r := require.New(t) + + saved := Mtime + defer func() { + Mtime = saved + }() + + Mtime = func(fi os.FileInfo) int64 { + switch fi.Name() { + case "file1.md": + return 1000 + case "file2.md": + return 2000 + case "nested.md": + return 3000 + default: + return 0 + } + } + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = fs.Write("/", "file1.md", "content1") + r.NoError(err) + err = fs.Write("today", "file2.md", "content2") + r.NoError(err) + err = fs.Write("/", "not-markdown.txt", "should be ignored") + r.NoError(err) + err = fs.MakeDir("today/subdir") + r.NoError(err) + err = fs.Write("today/subdir", "nested.md", "nested content") + r.NoError(err) + + // Create hidden file (should be ignored) + err = fs.Write("today", ".hidden.md", "hidden content") + r.NoError(err) + + // Test Ctimes + ctimes, err := fs.Mtimes("/", ".md") + r.NoError(err) + + fmt.Println(ctimes) + r.Len(ctimes, 3) + r.Equal(int64(1000), ctimes["file1.md"]) + r.Equal(int64(2000), ctimes["today/file2.md"]) + r.Equal(int64(3000), ctimes["today/subdir/nested.md"]) + + // Should not include non-markdown files or hidden files + _, exists := ctimes["today/not-markdown.txt"] + r.False(exists) + + _, exists = ctimes["today/.hidden.md"] + r.False(exists) +} + +func TestMtimesAllExtensions(t *testing.T) { + r := require.New(t) + + saved := Mtime + defer func() { + Mtime = saved + }() + + Mtime = func(fi os.FileInfo) int64 { + switch fi.Name() { + case "file1.md": + return 1000 + case "file2.md": + return 2000 + case "nested.md": + return 3000 + case "file.txt": + return 3000 + default: + return 0 + } + } + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = fs.Write("/", "file1.md", "content1") + r.NoError(err) + err = fs.Write("today", "file2.md", "content2") + r.NoError(err) + err = fs.Write("/", "file.txt", "should be ignored") + r.NoError(err) + err = fs.MakeDir("today/subdir") + r.NoError(err) + err = fs.Write("today/subdir", "nested.md", "nested content") + r.NoError(err) + + // Create hidden file (should be ignored) + err = fs.Write("today", ".hidden.md", "hidden content") + r.NoError(err) + + ctimes, err := fs.Mtimes("/") + r.NoError(err) + + r.Len(ctimes, 4) + r.Equal(int64(1000), ctimes["file1.md"]) + r.Equal(int64(2000), ctimes["today/file2.md"]) + r.Equal(int64(3000), ctimes["today/subdir/nested.md"]) + r.Equal(int64(3000), ctimes["file.txt"]) + + // Should not include non-markdown files or hidden files + _, exists := ctimes["today/not-markdown.txt"] + r.False(exists) + + _, exists = ctimes["today/.hidden.md"] + r.False(exists) +} + +func TestMtimesInSubDir(t *testing.T) { + r := require.New(t) + + saved := Mtime + defer func() { + Mtime = saved + }() + + Mtime = func(fi os.FileInfo) int64 { + switch fi.Name() { + case "file1.md": + return 1000 + case "file2.md": + return 2000 + case "nested.md": + return 3000 + default: + return 0 + } + } + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = fs.Write("/", "rootfile.md", "content1") + r.NoError(err) + err = fs.Write("today", "file1.md", "content1") + r.NoError(err) + err = fs.Write("today", "file2.md", "content2") + r.NoError(err) + err = fs.Write("today", "not-markdown.txt", "should be ignored") + r.NoError(err) + err = fs.MakeDir("today/subdir") + r.NoError(err) + err = fs.Write("today/subdir", "nested.md", "nested content") + r.NoError(err) + + // Create hidden file (should be ignored) + err = fs.Write("today", ".hidden.md", "hidden content") + r.NoError(err) + + // Test Ctimes + ctimes, err := fs.Mtimes("today", ".md") + r.NoError(err) + + // Should only include .md files, not .txt or hidden files + r.Len(ctimes, 3) + r.Equal(int64(1000), ctimes["file1.md"]) + r.Equal(int64(2000), ctimes["file2.md"]) + r.Equal(int64(3000), ctimes["subdir/nested.md"]) + + // Should not include non-markdown files or hidden files + _, exists := ctimes["today/not-markdown.txt"] + r.False(exists) + + _, exists = ctimes["today/.hidden.md"] + r.False(exists) +} + +func TestCtimesEmptyDir(t *testing.T) { + r := require.New(t) + + fs, err := NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + err = fs.MakeDir("empty") + r.NoError(err) + + ctimes, err := fs.Mtimes("empty", ".md") + r.NoError(err) + r.Empty(ctimes) +} diff --git a/server/fs/fs_wasm.go b/server/fs/fs_wasm.go new file mode 100644 index 0000000..0e5ba7e --- /dev/null +++ b/server/fs/fs_wasm.go @@ -0,0 +1,23 @@ +//go:build wasm + +package fs + +import ( + "os" +) + +var Ctime = func(fi os.FileInfo) int64 { + if fi == nil { + return 0 + } + + return fi.ModTime().UnixNano() +} + +var Mtime = func(fi os.FileInfo) int64 { + if fi == nil { + return 0 + } + + return fi.ModTime().UnixNano() +} diff --git a/server/fs/fs_windows.go b/server/fs/fs_windows.go new file mode 100644 index 0000000..f353aa0 --- /dev/null +++ b/server/fs/fs_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package fs + +import ( + "os" + "syscall" +) + +var Ctime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Win32FileAttributeData) + return stat.LastAccessTime.Nanoseconds() / 1000_000_000 +} + +var Mtime = func(fi os.FileInfo) int64 { + stat := fi.Sys().(*syscall.Win32FileAttributeData) + return stat.LastWriteTime.Nanoseconds() / 1000_000_000 +} diff --git a/server/fs/quota.go b/server/fs/quota.go new file mode 100644 index 0000000..cda3186 --- /dev/null +++ b/server/fs/quota.go @@ -0,0 +1,76 @@ +package fs + +import ( + "os" + "strconv" + "strings" + "sync" + + "github.com/spf13/afero" +) + +var ( + storageQuotaMu sync.RWMutex + storageQuotaCache = map[string]int64{} +) + +func storageUsed(rootPath string, backend afero.Fs) int64 { + storageQuotaMu.RLock() + used, ok := storageQuotaCache[rootPath] + storageQuotaMu.RUnlock() + if ok { + return used + } + + var total int64 + _ = afero.Walk(backend, rootPath, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + total += info.Size() + } + return nil + }) + + storageQuotaMu.Lock() + storageQuotaCache[rootPath] = total + storageQuotaMu.Unlock() + + return total +} + +func recordQuotaUsage(rootPath string, delta int64) { + storageQuotaMu.Lock() + if _, ok := storageQuotaCache[rootPath]; ok { + storageQuotaCache[rootPath] += delta + } + storageQuotaMu.Unlock() +} + +func checkQuota(rootPath string, backend afero.Fs, quotaKB int64, contentSize int64) error { + if quotaKB <= 0 { + return nil + } + + if storageUsed(rootPath, backend)+contentSize > quotaKB*1024 { + return ErrQuotaExceeded + } + + return nil +} + +func isUnlimitedQuota(userID int64, unlimitedIDs string) bool { + if unlimitedIDs == "" { + return false + } + + for _, idStr := range strings.Split(unlimitedIDs, ",") { + id, err := strconv.ParseInt(strings.TrimSpace(idStr), 10, 64) + if err != nil { + continue + } + if id == userID { + return true + } + } + + return false +} diff --git a/server/fs/testdata/fuzz/FuzzWrite/8f56bfc9ac97f267 b/server/fs/testdata/fuzz/FuzzWrite/8f56bfc9ac97f267 new file mode 100644 index 0000000..d04cc1f --- /dev/null +++ b/server/fs/testdata/fuzz/FuzzWrite/8f56bfc9ac97f267 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("..0") +string("0") +string("0") diff --git a/server/fs/testdata/fuzz/FuzzWrite/a9df0a6afc890b70 b/server/fs/testdata/fuzz/FuzzWrite/a9df0a6afc890b70 new file mode 100644 index 0000000..fca901d --- /dev/null +++ b/server/fs/testdata/fuzz/FuzzWrite/a9df0a6afc890b70 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("0") +string("..") +string("0") diff --git a/server/fs/testdata/fuzz/FuzzWrite/b9cc6f2d4e5a546f b/server/fs/testdata/fuzz/FuzzWrite/b9cc6f2d4e5a546f new file mode 100644 index 0000000..99b8eb8 --- /dev/null +++ b/server/fs/testdata/fuzz/FuzzWrite/b9cc6f2d4e5a546f @@ -0,0 +1,4 @@ +go test fuzz v1 +string("0") +string("..0") +string("0") diff --git a/server/habits/habits.go b/server/habits/habits.go new file mode 100644 index 0000000..fe9f865 --- /dev/null +++ b/server/habits/habits.go @@ -0,0 +1,270 @@ +package habits + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/rivo/uniseg" + + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/i18n" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +// [1 => false, => 0, 1...] +type Year map[int]int + +const ( + habitSkipped = "⚪️" + habitCompleted = "🟢" + habitCompletedAtWeekend = "🟡" + + MoodHabit = "Mood" +) + +var ( + MoodEmojis = []string{"⚪️", "🤕", "😔", "😐", "🙂", "😊"} + errMalformedMonthLine = errors.New("malformed month line") + now = time.Now +) + +// Habits returns Habit name => [day1 => 1, day2 => 0, ..., day365 => 0] +func Habits(userFS *fs.FS, year int) (map[string]Year, error) { + existingHabits, err := userFS.FilesAndDirs(fs.DirHabits) + if err != nil { + return nil, fmt.Errorf("habits: can't read existing habits: %w", err) + } + + habits := make(map[string]Year) + for _, existingHabit := range existingHabits { + habits[existingHabit.DisplayName] = make(Year) + } + + filename := fmt.Sprintf("%d Habits.md", year) + insightsExist, err := userFS.Exists(fs.DirInsights, filename) + if err != nil { + return nil, fmt.Errorf("habits: can't check whether the file insightsExist: %w", err) + } + if !insightsExist { + return habits, nil + } + + habitsForYearLines, err := userFS.Read(fs.DirInsights, filename) + if err != nil { + return nil, fmt.Errorf("habits:read %s error: %w", filename, err) + } + + month := time.January + lines := strings.Split(txt.NormNewLines(habitsForYearLines), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + + // Parsing month line + isMonthLine := strings.HasPrefix(line, "###") + if isMonthLine { + parts := strings.Split(line, " ") + if len(parts) < 2 { + return nil, fmt.Errorf("read habits: can't parse month line '%s': %w", line, errMalformedMonthLine) + } + + // We should extract only first month:"June, some,gibberish" + // TODO add tests + date, err := time.Parse("January", txt.FirstWord(parts[1])) + if err != nil { + return nil, fmt.Errorf("read habits: can't parse month %s: %w", line, err) + } + month = date.Month() + + continue + } + + // Tolerant reader, if we encounter gibberish, + // we skip it. See ADRs in README.md for details for details + // TODO preserve gibberish between parsing seesions + daysAndHabit := strings.SplitN(line, " ", 2) + if len(daysAndHabit) < 2 { + continue + } + days, habit := daysAndHabit[0], daysAndHabit[1] + + firstDayOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + dayOfTheYear := firstDayOfMonth.YearDay() + + // Moods line + moodsMarker := MoodHabit + if strings.Contains(habit, moodsMarker) { + gr := uniseg.NewGraphemes(days) + dayOffset := 0 + if _, ok := habits[MoodHabit]; !ok { + habits[MoodHabit] = make(Year) + } + for gr.Next() { + power := slices.Index(MoodEmojis, gr.Str()) + habits[MoodHabit][dayOfTheYear+dayOffset] = power + dayOfTheYear++ + } + continue + } + + // Skip gibberish + habitsMarker := fmt.Sprintf("%s%s%s", habitSkipped, habitCompletedAtWeekend, habitCompleted) + if !strings.ContainsAny(days, habitsMarker) { + continue + } + + // Habits line + // [⚪️🟢... Habit name] i.e. completion status + // for every day of the above found month + habitName := strings.TrimSpace(habit) + if _, ok := habits[habitName]; !ok { + habits[habitName] = make(Year) + } + + // See README.md ADRs + gr := uniseg.NewGraphemes(days) + dayOffset := 0 + for gr.Next() { + habits[habitName][dayOfTheYear+dayOffset] = 0 + if gr.Str() != habitSkipped { + habits[habitName][dayOfTheYear+dayOffset] = 1 + } + dayOfTheYear++ + } + } + + return habits, nil +} + +// LastWeekHabits returns Habit name => [day1 => 1, day2 => 0, ..., day365 => 0] +// with the year days falling in the last week +// FIXME doesn't work when a week falls into two years +func LastWeekHabits(userFS *fs.FS, tz *time.Location) (map[string]Year, error) { + habitsForYear, err := Habits(userFS, now().In(tz).Year()) + if err != nil { + return nil, fmt.Errorf("last week habits: can't get habits: %w", err) + } + + currentDay := now().In(tz) + for currentDay.Weekday() != time.Monday { + currentDay = currentDay.Add(-24 * time.Hour) + } + + existingHabits, err := userFS.FilesAndDirs(fs.DirHabits) + if err != nil { + return nil, fmt.Errorf("last week habits: can't read existing habits: %w", err) + } + // Add default mood habit which is not in habits folder + existingHabits = append(existingHabits, fs.File{Name: MoodHabit}) + + habits := make(map[string]Year) + for _, habit := range existingHabits { + habitName := strings.TrimSuffix(habit.Name, fs.MDExt) + habits[habitName] = make(Year) + for offset := range 7 { + yearDay := currentDay.Add(time.Duration(offset) * 24 * time.Hour).YearDay() + habits[habitName][yearDay] = 0 + if status, ok := habitsForYear[habitName][yearDay]; ok { + habits[habitName][yearDay] = status + } + } + } + + return habits, nil +} + +func Write(userFS *fs.FS, year int, habits map[string]Year) error { + habitKeys := make([]string, 0) + for k := range habits { + if k == MoodHabit { + continue + } + habitKeys = append(habitKeys, k) + } + sort.Strings(habitKeys) + if _, ok := habits[MoodHabit]; ok { + habitKeys = append(habitKeys, MoodHabit) + } + + content := "" + day := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) + for day.Year() < year+1 { + habitsForMonth := "" + for _, habitName := range habitKeys { + statuses := "" + dayOfMonth := day + atLeastOneCompletion := false + for dayOfMonth.Month() == day.Month() { + emoji := habitSkipped + if status, ok := habits[habitName][dayOfMonth.YearDay()]; ok { + emoji = emojiForStatus(habitName, dayOfMonth, status) + } + if emoji != habitSkipped { + atLeastOneCompletion = true + } + statuses += emoji + + dayOfMonth = dayOfMonth.AddDate(0, 0, 1) + } + if atLeastOneCompletion { + habitsForMonth += fmt.Sprintf("%s %s\n", statuses, habitName) + } + } + + if len(habitsForMonth) != 0 { + if len(content) > 0 { + content += "\n" + } + content += fmt.Sprintf("### %s\n%s", day.Month(), habitsForMonth) + } + + day = day.AddDate(0, 1, 0) + } + + filename := fmt.Sprintf("%d Habits.md", year) + err := userFS.Write(fs.DirInsights, filename, content) + if err != nil { + return fmt.Errorf("can't write habits: %w", err) + } + + return nil +} + +func Emoji(userFS *fs.FS, habitName string) string { + emoji, _ := userFS.Read(fs.DirHabits, fs.Filename(habitName)) + if emoji == "" { + emoji = i18n.Emoji(habitName) + } + if emoji == "" { + emoji = "⚡️" + } + + return emoji +} + +func emojiForStatus(habitName string, day time.Time, status int) string { + if habitName == MoodHabit { + if status < len(MoodEmojis) { + return MoodEmojis[status] + } + + return habitSkipped + } + + if status == 1 { + isWeekend := day.Weekday() == time.Saturday || day.Weekday() == time.Sunday + if isWeekend { + return habitCompletedAtWeekend + } else { + return habitCompleted + } + } + + return habitSkipped +} diff --git a/server/habits/habits_render.go b/server/habits/habits_render.go new file mode 100644 index 0000000..3f28f13 --- /dev/null +++ b/server/habits/habits_render.go @@ -0,0 +1,50 @@ +package habits + +import ( + "bytes" + _ "embed" + "fmt" + "html/template" + "time" + + "github.com/zakirullin/files.md/server/config" + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/userconfig" +) + +//go:embed templates/habits.html +var html string + +func Render(userID int64, userFS *fs.FS) ([]byte, error) { + tmpl, err := template.New("habits").Parse(html) + if err != nil { + return nil, fmt.Errorf("can't parse habits template: %w", err) + } + + cfg := userconfig.NewConfig(userFS, userID, config.ServerCfg.ConfigFilename) + + habits, err := LastWeekHabits(userFS, cfg.Timezone()) + if err != nil { + return nil, fmt.Errorf("can't render habit: %w", err) + } + + moods, ok := habits[MoodHabit] + if ok { + delete(habits, MoodHabit) + } + + var out bytes.Buffer + err = tmpl.Execute(&out, map[string]any{ + "habits": habits, + "moods": moods, + "moodEmojis": MoodEmojis, + "host": config.ServerCfg.APIHost(), + "userID": userID, + "currentDay": time.Now().YearDay(), + }) + if err != nil { + return nil, fmt.Errorf("can't render habits template: %w", err) + } + + return out.Bytes(), nil +} diff --git a/server/habits/habits_test.go b/server/habits/habits_test.go new file mode 100644 index 0000000..9561919 --- /dev/null +++ b/server/habits/habits_test.go @@ -0,0 +1,188 @@ +package habits + +// TODO one known bug - it won't correctly work +// if our week falls into 2 different years + +import ( + _ "embed" + "os" + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/zakirullin/files.md/server/fs" +) + +//go:embed testdata/month_habits.md +var monthMD string + +//go:embed testdata/last_month_habits.md +var lastMonthMD string + +//go:embed testdata/two_months_habits.md +var twoMonthsMD string + +func init() { + fs.Ctime = func(fi os.FileInfo) int64 { + return 0 + } +} + +func TestHabits(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write(fs.DirInsights, "1970 Habits.md", monthMD) + + habits, err := Habits(userFS, 1970) + r.NoError(err) + + r.Len(habits, 5) + year, ok := habits["Went to gym"] + r.True(ok) + + r.Len(year, 31) + + r.EqualValues(Year{1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 1, 7: 0, 8: 0, 9: 0, 10: 0, 11: 1, 12: 0, 13: 0, 14: 1, 15: 0, 16: 0, 17: 0, 18: 1, 19: 0, 20: 1, 21: 0, 22: 0, 23: 1, 24: 0, 25: 1, 26: 0, 27: 1, 28: 0, 29: 1, 30: 0, 31: 1}, year) +} + +func TestHabitsForTwoMonths(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write(fs.DirInsights, "1970 Habits.md", twoMonthsMD) + + habits, err := Habits(userFS, 1970) + r.NoError(err) + + r.Len(habits, 2) + year, ok := habits["Habit"] + r.True(ok) + + r.Len(year, 61) + + r.EqualValues(Year{244: 0, 245: 0, 246: 0, 247: 0, 248: 0, 249: 1, 250: 0, 251: 1, 252: 0, 253: 0, 254: 0, 255: 0, 256: 1, 257: 0, 258: 0, 259: 0, 260: 1, 261: 0, 262: 0, 263: 1, 264: 1, 265: 1, 266: 0, 267: 1, 268: 1, 269: 0, 270: 1, 271: 0, 272: 1, 273: 0, 274: 0, 275: 0, 276: 1, 277: 0, 278: 0, 279: 1, 280: 1, 281: 0, 282: 1, 283: 1, 284: 0, 285: 0, 286: 0, 287: 0, 288: 0, 289: 0, 290: 0, 291: 0, 292: 1, 293: 1, 294: 0, 295: 0, 296: 1, 297: 0, 298: 1, 299: 1, 300: 1, 301: 1, 302: 1, 303: 0, 304: 1}, year) +} + +func TestLastMonthHabits(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write(fs.DirInsights, "1970 Habits.md", lastMonthMD) + + habits, err := Habits(userFS, 1970) + r.NoError(err) + + r.Len(habits, 1) + year, ok := habits["Habit"] + r.True(ok) + + r.Len(year, 31) + + completed, ok := year[335] + r.True(ok) + r.Equal(0, completed) + + completed, ok = year[365] + r.True(ok) + r.Equal(1, completed) +} + +func TestLastWeekHabitsWhenWeekFallsIntoTwoMonths(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write(fs.DirInsights, "1970 Habits.md", twoMonthsMD) + _ = userFS.Write(fs.DirHabits, "Habit.md", "") + + savedNow := now + defer func() { + now = savedNow + }() + now = func() time.Time { + return time.Date(1970, time.September, 30, 0, 0, 0, 0, time.Local) + } + + habits, err := LastWeekHabits(userFS, time.UTC) + r.NoError(err) + r.Len(habits, 2) + r.Len(habits["Habit"], 7) + r.EqualValues(Year{271: 0, 272: 1, 273: 0, 274: 0, 275: 0, 276: 1, 277: 0}, habits["Habit"]) + r.EqualValues(Year{271: 5, 272: 2, 273: 5, 274: 0, 275: 5, 276: 4, 277: 0}, habits["Mood"]) +} + +func TestLastMonthHabitsMoods(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write(fs.DirInsights, "1970 Habits.md", monthMD) + + habits, err := Habits(userFS, 1970) + r.NoError(err) + + year, ok := habits["Mood"] + r.True(ok) + + r.Len(year, 31) + + r.EqualValues(Year{1: 5, 2: 0, 3: 3, 4: 1, 5: 0, 6: 5, 7: 5, 8: 0, 9: 0, 10: 0, 11: 5, 12: 0, 13: 5, 14: 2, 15: 4, 16: 1, 17: 0, 18: 5, 19: 0, 20: 4, 21: 0, 22: 5, 23: 0, 24: 5, 25: 4, 26: 0, 27: 5, 28: 4, 29: 0, 30: 5, 31: 0}, year) +} + +func TestWrite(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write("insights", "2024 Habits.md", monthMD) + + habits, err := Habits(userFS, 2024) + r.NoError(err) + + err = Write(userFS, 2024, habits) + r.NoError(err) + + updatedMonthMD, err := userFS.Read("insights", "2024 Habits.md") + r.NoError(err) + + r.Equal(monthMD, updatedMonthMD) +} + +func TestWritePreserveMoodsForPreviousMonth(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.CreateSystemDirs() + r.NoError(err) + _ = userFS.Write("insights", "1970 Habits.md", twoMonthsMD) + + habits, err := Habits(userFS, 1970) + r.NoError(err) + + err = Write(userFS, 1970, habits) + r.NoError(err) + + updatedMD, err := userFS.Read("insights", "1970 Habits.md") + r.NoError(err) + + r.Equal(twoMonthsMD, updatedMD) +} diff --git a/server/habits/templates/habits.html b/server/habits/templates/habits.html new file mode 100644 index 0000000..89facdb --- /dev/null +++ b/server/habits/templates/habits.html @@ -0,0 +1,161 @@ + + + + + + Habits + + + + +
+ + + {{ range $habit, $year := .habits }} + + {{ $firstLine := true }} + {{ range $day, $status := .}} + + {{ $firstLine = false }} + {{ end }} + + {{ end }} + + {{ if eq (index $.moods $.currentDay) 0}} + + + + + + + + {{ else }} + {{ range .moods }} + + {{ end }} + {{ end }} + + +
{{ if $firstLine }}
{{$habit}}
{{ end }}
🤕😔😐🙂😊{{ index $.moodEmojis . }}
+
+ + + + + \ No newline at end of file diff --git a/server/habits/testdata/last_month_habits.md b/server/habits/testdata/last_month_habits.md new file mode 100644 index 0000000..864a83c --- /dev/null +++ b/server/habits/testdata/last_month_habits.md @@ -0,0 +1,2 @@ +### December +⚪️⚪️🟢⚪️⚪️🟡🟡⚪️🟢🟢⚪️⚪️⚪️⚪️⚪️⚪️⚪️⚪️🟢🟡⚪️⚪️🟢⚪️🟢🟢🟡🟡🟢⚪️🟢 Habit \ No newline at end of file diff --git a/server/habits/testdata/month_habits.md b/server/habits/testdata/month_habits.md new file mode 100644 index 0000000..0fdd942 --- /dev/null +++ b/server/habits/testdata/month_habits.md @@ -0,0 +1,6 @@ +### January +⚪️⚪️🟢⚪️⚪️🟡🟡⚪️🟢🟢⚪️⚪️⚪️⚪️⚪️⚪️⚪️⚪️🟢🟡⚪️⚪️🟢⚪️🟢🟢🟡🟡🟢⚪️🟢 2 minute morning workout +⚪️⚪️⚪️⚪️⚪️🟡⚪️🟢⚪️⚪️⚪️⚪️🟡⚪️⚪️⚪️🟢⚪️⚪️🟡🟡🟢⚪️🟢🟢⚪️🟡⚪️🟢🟢🟢 Ate consciously +⚪️⚪️🟢⚪️⚪️🟡⚪️🟢🟢⚪️🟢⚪️⚪️⚪️⚪️⚪️🟢🟢⚪️🟡🟡🟢🟢⚪️⚪️⚪️⚪️⚪️🟢⚪️🟢 Kept posture for 2 min +⚪️⚪️⚪️⚪️⚪️🟡⚪️⚪️⚪️⚪️🟢⚪️⚪️🟡⚪️⚪️⚪️🟢⚪️🟡⚪️⚪️🟢⚪️🟢⚪️🟡⚪️🟢⚪️🟢 Went to gym +😊⚪️😐🤕⚪️😊😊⚪️⚪️⚪️😊⚪️😊😔🙂🤕⚪️😊⚪️🙂⚪️😊⚪️😊🙂⚪️😊🙂⚪️😊⚪️ Mood diff --git a/server/habits/testdata/month_habits_gibberish.md b/server/habits/testdata/month_habits_gibberish.md new file mode 100644 index 0000000..df2fa06 --- /dev/null +++ b/server/habits/testdata/month_habits_gibberish.md @@ -0,0 +1,9 @@ +Gibberish +### January +⚪️⚪️🟢⚪️⚪️🟡🟡⚪️🟢🟢⚪️⚪️⚪️⚪️⚪️⚪️⚪️⚪️🟢🟡⚪️⚪️🟢⚪️🟢🟢🟡🟡🟢⚪️🟢 2 minute morning workout +⚪️⚪️⚪️⚪️⚪️🟡⚪️🟢⚪️⚪️⚪️⚪️🟡⚪️⚪️⚪️🟢⚪️⚪️🟡🟡🟢⚪️🟢🟢⚪️🟡⚪️🟢🟢🟢 Ate consciously +⚪️⚪️🟢⚪️⚪️🟡⚪️🟢🟢⚪️🟢⚪️⚪️⚪️⚪️⚪️🟢🟢⚪️🟡🟡🟢🟢⚪️⚪️⚪️⚪️⚪️🟢⚪️🟢 Kept posture for 2 min +⚪️⚪️⚪️⚪️⚪️🟡⚪️⚪️⚪️⚪️🟢⚪️⚪️🟡⚪️⚪️⚪️🟢⚪️🟡⚪️⚪️🟢⚪️🟢⚪️🟡⚪️🟢⚪️🟢 Went to gym +😊⚪️😐🤕⚪️😊😊⚪️⚪️⚪️😊⚪️😊😔🙂🤕⚪️😊⚪️🙂⚪️😊⚪️😊🙂⚪️😊🙂⚪️😊⚪️ Mood + + ~Gibberish~ \ No newline at end of file diff --git a/server/habits/testdata/two_months_habits.md b/server/habits/testdata/two_months_habits.md new file mode 100644 index 0000000..4d51a0a --- /dev/null +++ b/server/habits/testdata/two_months_habits.md @@ -0,0 +1,7 @@ +### September +⚪️⚪️⚪️⚪️⚪️🟡⚪️🟢⚪️⚪️⚪️⚪️🟡⚪️⚪️⚪️🟢⚪️⚪️🟡🟢🟢⚪️🟢🟢⚪️🟡⚪️🟢⚪️ Habit +🙂😐🙂😊😊😐🙂😊😊😐😊🙂🤕🤕😔🙂🤕🙂🙂😊🙂🙂⚪️😐😊😊🙂😊😔😊 Mood + +### October +⚪️⚪️🟡⚪️⚪️🟢🟢⚪️🟢🟡⚪️⚪️⚪️⚪️⚪️⚪️⚪️⚪️🟢🟢⚪️⚪️🟢⚪️🟡🟢🟢🟢🟢⚪️🟡 Habit +⚪️😊🙂⚪️🙂😊🤕😊😐😐⚪️⚪️⚪️⚪️⚪️🤕🙂😐🙂⚪️🙂⚪️😊😊🙂🙂😊⚪️⚪️⚪️⚪️ Mood diff --git a/server/i18n/emoji.go b/server/i18n/emoji.go new file mode 100644 index 0000000..42422b8 --- /dev/null +++ b/server/i18n/emoji.go @@ -0,0 +1,63 @@ +package i18n + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" +) + +var emojisByKeyword map[string]string + +//go:embed emojis.json +var emojisJSON string + +func LoadEmojiFile() { + var emojis map[string][]string + err := json.Unmarshal([]byte(emojisJSON), &emojis) + if err != nil { + panic(fmt.Errorf("i18n.loadEmojiFile: can't unmarshal: %w", err)) + } + + emojisByKeyword = make(map[string]string) + for emoji, keywords := range emojis { + for _, keyword := range keywords { + emojisByKeyword[keyword] = emoji + } + } +} + +// AddEmoji adds auto emoji to a string based on keywords. +// TODO add split to spaces etc +func AddEmoji(str string) string { + emoji := Emoji(str) + if len(emoji) == 0 { + return str + } + + return fmt.Sprintf("%s %s", emoji, str) +} + +func Emoji(str string) string { + if len(emojisByKeyword) == 0 { + LoadEmojiFile() + } + + strLower := strings.ToLower(str) + aliases := []string{strLower, strLower + "s", strings.TrimSuffix(strLower, "s")} + for _, alias := range aliases { + icon := emojisByKeyword[alias] + if icon != "" { + return icon + } + } + + for _, word := range strings.Fields(strLower) { + icon := emojisByKeyword[word] + if icon != "" { + return icon + } + } + + return "" +} diff --git a/server/i18n/emojis.json b/server/i18n/emojis.json new file mode 100644 index 0000000..e014a1f --- /dev/null +++ b/server/i18n/emojis.json @@ -0,0 +1,12368 @@ +{ + "😀": [ + "grinning_face", + "face", + "smile", + "happy", + "joy", + ":D", + "grin" + ], + "😃": [ + "grinning_face_with_big_eyes", + "face", + "happy", + "joy", + "haha", + ":D", + ":)", + "smile", + "funny", + "лицо", + "счастье", + "радость", + "хаха", + ":D", + ":)", + "улыбка", + "смешно" + ], + "😄": [ + "grinning_face_with_smiling_eyes", + "face", + "happy", + "joy", + "funny", + "haha", + "laugh", + "like", + ":D", + ":)", + "smile", + "лицо", + "счастье", + "радость", + "смешной", + "хаха", + "смех", + "нравится", + ":D", + ":)", + "smile" + ], + "😁": [ + "beaming_face_with_smiling_eyes", + "face", + "happy", + "smile", + "joy", + "kawaii", + "лицо", + "счастье", + "улыбка", + "радость", + "kawaii" + ], + "😆": [ + "grinning_squinting_face", + "happy", + "joy", + "lol", + "satisfied", + "haha", + "face", + "glad", + "XD", + "laugh" + ], + "😅": [ + "grinning_face_with_sweat", + "face", + "hot", + "happy", + "laugh", + "sweat", + "smile", + "relief" + ], + "🤣": [ + "rolling_on_the_floor_laughing", + "face", + "rolling", + "floor", + "laughing", + "lol", + "haha", + "rofl" + ], + "😂": [ + "face_with_tears_of_joy", + "face", + "cry", + "tears", + "weep", + "happy", + "happytears", + "haha" + ], + "🙂": [ + "slightly_smiling_face", + "face", + "smile" + ], + "🙃": [ + "upside_down_face", + "face", + "flipped", + "silly", + "smile" + ], + "😉": [ + "winking_face", + "face", + "happy", + "mischievous", + "secret", + ";)", + "smile", + "eye", + "winking_face", + "лицо", + "счастливый", + "озорной", + "secret", + ";)", + "улыбка", + "глаз" + ], + "😊": [ + "smiling_face_with_smiling_eyes", + "face", + "smile", + "happy", + "flushed", + "crush", + "embarrassed", + "shy", + "joy", + "лицо", + "улыбка", + "счастливый", + "раскрасневшийся", + "сокрушаться", + "смущенный", + "застенчивый", + "радость" + ], + "😇": [ + "smiling_face_with_halo", + "face", + "angel", + "heaven", + "halo", + "innocent", + "лицо", + "ангел", + "небо", + "нимб", + "невинный" + ], + "🥰": [ + "smiling_face_with_hearts", + "face", + "love", + "like", + "affection", + "valentines", + "infatuation", + "crush", + "hearts", + "adore", + "лицо", + "любовь", + "нравится", + "привязанность", + "валентинки", + "увлечение", + "влюбленность", + "сердца", + "обожание" + ], + "😍": [ + "smiling_face_with_heart_eyes", + "face", + "love", + "like", + "affection", + "valentines", + "infatuation", + "crush", + "heart" + ], + "🤩": [ + "star_struck", + "face", + "smile", + "starry", + "grinning" + ], + "😘": [ + "face_blowing_a_kiss", + "face", + "love", + "like", + "affection", + "valentines", + "infatuation", + "kiss" + ], + "😗": [ + "kissing_face", + "love", + "like", + "face", + "3", + "valentines", + "infatuation", + "kiss" + ], + "☺️": [ + "smiling_face", + "face", + "blush", + "massage", + "happiness" + ], + "😚": [ + "kissing_face_with_closed_eyes", + "face", + "love", + "like", + "affection", + "valentines", + "infatuation", + "kiss" + ], + "😙": [ + "kissing_face_with_smiling_eyes", + "face", + "affection", + "valentines", + "infatuation", + "kiss" + ], + "😋": [ + "face_savoring_food", + "happy", + "joy", + "tongue", + "smile", + "face", + "silly", + "yummy", + "nom", + "delicious", + "savouring" + ], + "😛": [ + "face_with_tongue", + "face", + "prank", + "childish", + "playful", + "mischievous", + "smile", + "tongue" + ], + "😜": [ + "winking_face_with_tongue", + "face", + "prank", + "childish", + "playful", + "mischievous", + "smile", + "wink", + "tongue" + ], + "🤪": [ + "zany_face", + "face", + "goofy", + "crazy" + ], + "😝": [ + "squinting_face_with_tongue", + "face", + "prank", + "playful", + "mischievous", + "smile", + "tongue" + ], + "🤑": [ + "money_mouth_face", + "face", + "rich", + "dollar", + "money" + ], + "🤗": [ + "hugging_face", + "face", + "smile", + "hug" + ], + "🤭": [ + "face_with_hand_over_mouth", + "face", + "whoops", + "shock", + "surprise" + ], + "🤫": [ + "shushing_face", + "face", + "quiet", + "shhh" + ], + "🤔": [ + "thinking_face", + "face", + "hmmm", + "think", + "consider" + ], + "🤐": [ + "zipper_mouth_face", + "face", + "sealed", + "zipper", + "secret" + ], + "🤨": [ + "face_with_raised_eyebrow", + "face", + "distrust", + "scepticism", + "disapproval", + "disbelief", + "surprise" + ], + "😐": [ + "neutral_face", + "indifference", + "meh", + ":|", + "neutral" + ], + "😑": [ + "expressionless_face", + "face", + "indifferent", + "-_-", + "meh", + "deadpan" + ], + "😶": [ + "face_without_mouth", + "face", + "hellokitty" + ], + "😏": [ + "smirking_face", + "face", + "smile", + "mean", + "prank", + "smug", + "sarcasm" + ], + "😒": [ + "unamused_face", + "indifference", + "bored", + "straight face", + "serious", + "sarcasm", + "unimpressed", + "skeptical", + "dubious", + "side_eye" + ], + "🙄": [ + "face_with_rolling_eyes", + "face", + "eyeroll", + "frustrated" + ], + "😬": [ + "grimacing_face", + "face", + "grimace", + "teeth" + ], + "🤥": [ + "lying_face", + "face", + "lie", + "pinocchio" + ], + "😌": [ + "relieved_face", + "face", + "relaxed", + "phew", + "massage", + "happiness" + ], + "😔": [ + "pensive_face", + "face", + "sad", + "depressed", + "upset" + ], + "😪": [ + "sleepy_face", + "face", + "tired", + "rest", + "nap" + ], + "🤤": [ + "drooling_face", + "face" + ], + "😴": [ + "sleeping_face", + "face", + "tired", + "sleepy", + "night", + "zzz" + ], + "😷": [ + "face_with_medical_mask", + "face", + "sick", + "ill", + "disease", + "covid" + ], + "🤒": [ + "face_with_thermometer", + "sick", + "temperature", + "thermometer", + "cold", + "fever", + "covid", + "лицо_с_термометром", + "больной", + "температура", + "термометр", + "холод", + "жар", + "ковид" + ], + "🤕": [ + "face_with_head_bandage", + "injured", + "clumsy", + "bandage", + "hurt" + ], + "🤢": [ + "nauseated_face", + "face", + "vomit", + "gross", + "green", + "sick", + "throw up", + "ill" + ], + "🤮": [ + "face_vomiting", + "face", + "sick" + ], + "🤧": [ + "sneezing_face", + "face", + "gesundheit", + "sneeze", + "sick", + "allergy" + ], + "🥵": [ + "hot_face", + "face", + "feverish", + "heat", + "red", + "sweating" + ], + "🥶": [ + "cold_face", + "face", + "blue", + "freezing", + "frozen", + "frostbite", + "icicles", + "cold_face", + "лицо", + "синий", + "freezing", + "замороженный", + "обморожение", + "сосульки" + ], + "🥴": [ + "woozy_face", + "face", + "dizzy", + "intoxicated", + "tipsy", + "wavy" + ], + "😵": [ + "dizzy_face", + "spent", + "unconscious", + "xox", + "dizzy" + ], + "🤯": [ + "exploding_head", + "face", + "shocked", + "blown" + ], + "🤠": [ + "cowboy_hat_face", + "face", + "cowgirl", + "hat" + ], + "🥳": [ + "partying_face", + "face", + "celebration", + "woohoo" + ], + "😎": [ + "smiling_face_with_sunglasses", + "face", + "cool", + "smile", + "summer", + "beach", + "sunglass" + ], + "🤓": [ + "nerd_face", + "face", + "nerdy", + "geek", + "dork" + ], + "🧐": [ + "face_with_monocle", + "face", + "stuffy", + "wealthy" + ], + "😕": [ + "confused_face", + "face", + "indifference", + "huh", + "weird", + "hmmm", + ":\/" + ], + "😟": [ + "worried_face", + "face", + "concern", + "nervous", + ":(" + ], + "🙁": [ + "slightly_frowning_face", + "face", + "frowning", + "disappointed", + "sad", + "upset" + ], + "☹️": [ + "frowning_face", + "face", + "sad", + "upset", + "frown" + ], + "😮": [ + "face_with_open_mouth", + "face", + "surprise", + "impressed", + "wow", + "whoa", + ":O" + ], + "😯": [ + "hushed_face", + "face", + "woo", + "shh" + ], + "😲": [ + "astonished_face", + "face", + "xox", + "surprised", + "poisoned" + ], + "😳": [ + "flushed_face", + "face", + "blush", + "shy", + "flattered" + ], + "🥺": [ + "pleading_face", + "face", + "begging", + "mercy", + "cry", + "tears", + "sad", + "grievance" + ], + "😦": [ + "frowning_face_with_open_mouth", + "face", + "aw", + "what" + ], + "😧": [ + "anguished_face", + "face", + "stunned", + "nervous" + ], + "😨": [ + "fearful_face", + "face", + "scared", + "terrified", + "nervous" + ], + "😰": [ + "anxious_face_with_sweat", + "face", + "nervous", + "sweat" + ], + "😥": [ + "sad_but_relieved_face", + "face", + "phew", + "sweat", + "nervous" + ], + "😢": [ + "crying_face", + "face", + "tears", + "sad", + "depressed", + "upset", + ":\\'(" + ], + "😭": [ + "loudly_crying_face", + "face", + "cry", + "tears", + "sad", + "upset", + "depressed" + ], + "😱": [ + "face_screaming_in_fear", + "face", + "munch", + "scared", + "omg" + ], + "😖": [ + "confounded_face", + "face", + "confused", + "sick", + "unwell", + "oops", + ":S" + ], + "😣": [ + "persevering_face", + "face", + "sick", + "no", + "upset", + "oops" + ], + "😞": [ + "disappointed_face", + "face", + "sad", + "upset", + "depressed", + ":(" + ], + "😓": [ + "downcast_face_with_sweat", + "face", + "hot", + "sad", + "tired", + "exercise" + ], + "😩": [ + "weary_face", + "face", + "tired", + "sleepy", + "sad", + "frustrated", + "upset" + ], + "😫": [ + "tired_face", + "sick", + "whine", + "upset", + "frustrated" + ], + "🥱": [ + "yawning_face", + "tired", + "sleepy" + ], + "😤": [ + "face_with_steam_from_nose", + "face", + "gas", + "phew", + "proud", + "pride" + ], + "😡": [ + "pouting_face", + "angry", + "mad", + "hate", + "despise" + ], + "😠": [ + "angry_face", + "mad", + "face", + "annoyed", + "frustrated" + ], + "🤬": [ + "face_with_symbols_on_mouth", + "face", + "swearing", + "cursing", + "cussing", + "profanity", + "expletive" + ], + "😈": [ + "smiling_face_with_horns", + "devil", + "horns" + ], + "👿": [ + "angry_face_with_horns", + "devil", + "angry", + "horns" + ], + "💀": [ + "skull", + "dead", + "skeleton", + "creepy", + "death" + ], + "☠️": [ + "skull_and_crossbones", + "poison", + "danger", + "deadly", + "scary", + "death", + "pirate", + "evil" + ], + "💩": [ + "pile_of_poo", + "hankey", + "shitface", + "fail", + "turd", + "shit" + ], + "🤡": [ + "clown_face", + "face" + ], + "👹": [ + "ogre", + "monster", + "red", + "mask", + "halloween", + "scary", + "creepy", + "devil", + "demon", + "japanese", + "ogre" + ], + "👺": [ + "goblin", + "red", + "evil", + "mask", + "monster", + "scary", + "creepy", + "japanese", + "goblin" + ], + "👻": [ + "ghost", + "halloween", + "spooky", + "scary" + ], + "👽": [ + "alien", + "UFO", + "paul", + "weird", + "outer_space" + ], + "👾": [ + "alien_monster", + "game", + "arcade", + "play" + ], + "🤖": [ + "robot", + "computer", + "machine", + "bot" + ], + "😺": [ + "grinning_cat", + "animal", + "cats", + "happy", + "smile" + ], + "😸": [ + "grinning_cat_with_smiling_eyes", + "animal", + "cats", + "smile" + ], + "😹": [ + "cat_with_tears_of_joy", + "animal", + "cats", + "haha", + "happy", + "tears" + ], + "😻": [ + "smiling_cat_with_heart_eyes", + "animal", + "love", + "like", + "affection", + "cats", + "valentines", + "heart" + ], + "😼": [ + "cat_with_wry_smile", + "animal", + "cats", + "smirk" + ], + "😽": [ + "kissing_cat", + "animal", + "cats", + "kiss" + ], + "🙀": [ + "weary_cat", + "animal", + "cats", + "munch", + "scared", + "scream" + ], + "😿": [ + "crying_cat", + "animal", + "tears", + "weep", + "sad", + "cats", + "upset", + "cry" + ], + "😾": [ + "pouting_cat", + "animal", + "cats" + ], + "🙈": [ + "see_no_evil_monkey", + "monkey", + "animal", + "nature", + "haha" + ], + "🙉": [ + "hear_no_evil_monkey", + "animal", + "monkey", + "nature" + ], + "🙊": [ + "speak_no_evil_monkey", + "monkey", + "animal", + "nature", + "omg" + ], + "💋": [ + "kiss_mark", + "face", + "lips", + "love", + "like", + "affection", + "valentines" + ], + "💌": [ + "love_letter", + "email", + "like", + "affection", + "envelope", + "valentines" + ], + "💘": [ + "heart_with_arrow", + "love", + "like", + "heart", + "affection", + "valentines" + ], + "💝": [ + "heart_with_ribbon", + "love", + "valentines" + ], + "💖": [ + "sparkling_heart", + "love", + "like", + "affection", + "valentines" + ], + "💗": [ + "growing_heart", + "like", + "love", + "affection", + "valentines", + "pink" + ], + "💓": [ + "beating_heart", + "love", + "like", + "affection", + "valentines", + "pink", + "heart" + ], + "💞": [ + "revolving_hearts", + "love", + "like", + "affection", + "valentines" + ], + "💕": [ + "two_hearts", + "love", + "like", + "affection", + "valentines", + "heart" + ], + "💟": [ + "heart_decoration", + "purple-square", + "love", + "like" + ], + "❣️": [ + "heart_exclamation", + "decoration", + "love" + ], + "💔": [ + "broken_heart", + "sad", + "sorry", + "break", + "heart", + "heartbreak" + ], + "❤️": [ + "red_heart", + "love", + "like", + "valentines" + ], + "🧡": [ + "orange_heart", + "love", + "like", + "affection", + "valentines" + ], + "💛": [ + "yellow_heart", + "love", + "like", + "affection", + "valentines", + "cases" + ], + "💚": [ + "journal", + "green_heart", + "love", + "like", + "affection", + "valentines", + "move to journal" + + ], + "💙": [ + "blue_heart", + "love", + "like", + "affection", + "valentines", + "social" + ], + "💜": [ + "purple_heart", + "love", + "like", + "affection", + "valentines" + ], + "🤎": [ + "brown_heart", + "coffee" + ], + "🖤": [ + "black_heart", + "evil" + ], + "🤍": [ + "white_heart", + "pure" + ], + "💯": [ + "hundred_points", + "score", + "perfect", + "numbers", + "century", + "exam", + "quiz", + "pass", + "hundred" + ], + "💢": [ + "anger_symbol", + "angry", + "mad" + ], + "💥": [ + "collision", + "bomb", + "explode", + "explosion", + "collision", + "blown" + ], + "💫": [ + "dizzy", + "star", + "sparkle", + "shoot", + "magic" + ], + "💦": [ + "sweat_droplets", + "water", + "drip", + "oops" + ], + "💨": [ + "dashing_away", + "wind", + "air", + "fast", + "shoo", + "fart", + "smoke", + "puff" + ], + "🕳️": [ + "hole", + "embarrassing" + ], + "💣": [ + "bomb", + "boom", + "explode", + "explosion", + "terrorism" + ], + "💬": [ + "speech_balloon", + "bubble", + "words", + "message", + "talk", + "chatting", + "answer", + "ответить", + "reply", + "chat" + ], + "👁️‍🗨️": [ + "eye_in_speech_bubble", + "info" + ], + "🗨️": [ + "left_speech_bubble", + "words", + "message", + "talk", + "chatting" + ], + "🗯️": [ + "right_anger_bubble", + "caption", + "speech", + "thinking", + "mad" + ], + "💭": [ + "thought_balloon", + "bubble", + "cloud", + "speech", + "thinking", + "dream" + ], + "💤": [ + "zzz", + "sleepy", + "tired", + "dream" + ], + "👋": [ + "waving_hand", + "hands", + "gesture", + "goodbye", + "solong", + "farewell", + "hello", + "hi" + ], + "🤚": [ + "raised_back_of_hand", + "fingers", + "raised", + "backhand" + ], + "🖐️": [ + "hand_with_fingers_splayed", + "hand", + "fingers" + ], + "✋": [ + "raised_hand", + "fingers", + "stop", + "highfive", + "ban" + ], + "🖖": [ + "vulcan_salute", + "hand", + "fingers", + "spock", + "star trek" + ], + "👌": [ + "ok_hand", + "fingers", + "limbs", + "perfect", + "ok", + "okay" + ], + "🛀": [ + "person_taking_bath", + "clean", + "shower", + "bathroom" + ], + "🤏": [ + "pinching_hand", + "tiny", + "small", + "size", + "take", + "взять", + "забрать", + "give", + "отдать", + "дать" + ], + "✌️": [ + "victory_hand", + "fingers", + "ohyeah", + "hand", + "peace", + "victory", + "two" + ], + "🤞": [ + "crossed_fingers", + "good", + "lucky" + ], + "🤟": [ + "love_you_gesture", + "hand", + "fingers", + "gesture" + ], + "🤘": [ + "sign_of_the_horns", + "hand", + "fingers", + "evil_eye", + "sign_of_horns", + "rock_on" + ], + "🤙": [ + "call_me_hand", + "hands", + "gesture", + "shaka" + ], + "👈": [ + "backhand_index_pointing_left", + "direction", + "fingers", + "hand", + "left" + ], + "👉": [ + "backhand_index_pointing_right", + "fingers", + "hand", + "direction", + "right" + ], + "👆": [ + "backhand_index_pointing_up", + "fingers", + "hand", + "direction", + "up" + ], + "🖕": [ + "middle_finger", + "hand", + "fingers", + "rude", + "middle", + "flipping" + ], + "👇": [ + "backhand_index_pointing_down", + "fingers", + "hand", + "direction", + "down" + ], + "☝️": [ + "index_pointing_up", + "hand", + "fingers", + "direction", + "up" + ], + "👍": [ + "thumbs_up", + "thumbsup", + "yes", + "awesome", + "good", + "agree", + "accept", + "cool", + "hand", + "like", + "+1" + ], + "👎": [ + "thumbs_down", + "thumbsdown", + "no", + "dislike", + "hand", + "-1" + ], + "✊": [ + "raised_fist", + "fingers", + "hand", + "grasp" + ], + "👊": [ + "oncoming_fist", + "angry", + "violence", + "fist", + "hit", + "attack", + "hand" + ], + "🤛": [ + "left_facing_fist", + "hand", + "fistbump" + ], + "🤜": [ + "right_facing_fist", + "hand", + "fistbump" + ], + "👏": [ + "clapping_hands", + "hands", + "praise", + "applause", + "congrats", + "yay" + ], + "🙌": [ + "raising_hands", + "gesture", + "hooray", + "yea", + "celebration", + "hands" + ], + "👐": [ + "open_hands", + "fingers", + "butterfly", + "hands", + "open", + "открыть" + ], + "🤲": [ + "palms_up_together", + "hands", + "gesture", + "cupped", + "prayer" + ], + "🤝": [ + "handshake", + "agreement", + "shake" + ], + "🙏": [ + "folded_hands", + "please", + "hope", + "wish", + "namaste", + "highfive", + "pray", + "thank you", + "thanks", + "appreciate" + ], + "✍️": [ + "writing_hand", + "lower_left_ballpoint_pen", + "stationery", + "write", + "rename", + "compose", + "накидать" + ], + "💅": [ + "nail_polish", + "beauty", + "manicure", + "finger", + "fashion", + "nail" + ], + "🤳": [ + "selfie", + "camera", + "phone" + ], + "💪": [ + "flexed_biceps", + "arm", + "flex", + "hand", + "summer", + "strong", + "biceps" + ], + "🦾": [ + "mechanical_arm", + "accessibility" + ], + "🦿": [ + "mechanical_leg", + "accessibility" + ], + "🦵": [ + "leg", + "kick", + "limb" + ], + "🦶": [ + "foot", + "kick", + "stomp" + ], + "👂": [ + "ear", + "face", + "hear", + "sound", + "listen" + ], + "🦻": [ + "ear_with_hearing_aid", + "accessibility" + ], + "👃": [ + "nose", + "smell", + "sniff" + ], + "🧠": [ + "brain", + "smart", + "intelligent", + "mind" + ], + "🦷": [ + "tooth", + "teeth", + "dentist", + "toothpaste" + ], + "🦴": [ + "bone", + "skeleton" + ], + "👀" : [ + "eyes" + ], + "👁️": [ + "eye", + "face", + "look", + "see", + "stare", + "stalk", + "peek", + "see", + "review", + "посмотреть" + ], + "👅": [ + "tongue", + "mouth", + "playful" + ], + "👄": [ + "mouth", + "mouth", + "kiss" + ], + "👶": [ + "baby", + "child", + "boy", + "girl", + "toddler" + ], + "🧒": [ + "child", + "gender-neutral", + "young" + ], + "👦": [ + "boy", + "man", + "male", + "guy", + "teenager" + ], + "👧": [ + "girl", + "female", + "woman", + "teenager" + ], + "🧑": [ + "person", + "gender-neutral", + "person" + ], + "👱": [ + "person_blond_hair", + "hairstyle" + ], + "👨": [ + "man", + "mustache", + "father", + "dad", + "guy", + "classy", + "sir", + "moustache" + ], + "🧔": [ + "man_beard", + "person", + "bewhiskered" + ], + "👨‍🦰": [ + "man_red_hair", + "hairstyle" + ], + "👨‍🦱": [ + "man_curly_hair", + "hairstyle" + ], + "👨‍🦳": [ + "man_white_hair", + "old", + "elder" + ], + "👨‍🦲": [ + "man_bald", + "hairless" + ], + "👩": [ + "woman", + "female", + "girls", + "lady" + ], + "👩‍🦰": [ + "woman_red_hair", + "hairstyle" + ], + "🧑‍🦰": [ + "person_red_hair", + "hairstyle" + ], + "👩‍🦱": [ + "woman_curly_hair", + "hairstyle" + ], + "🧑‍🦱": [ + "person_curly_hair", + "hairstyle" + ], + "👩‍🦳": [ + "woman_white_hair", + "old", + "elder" + ], + "🧑‍🦳": [ + "person_white_hair", + "elder", + "old" + ], + "👩‍🦲": [ + "woman_bald", + "hairless" + ], + "🧑‍🦲": [ + "person_bald", + "hairless" + ], + "👱‍♀️": [ + "woman_blond_hair", + "woman", + "female", + "girl", + "blonde", + "person" + ], + "👱‍♂️": [ + "man_blond_hair", + "man", + "male", + "boy", + "blonde", + "guy", + "person" + ], + "🧓": [ + "older_person", + "human", + "elder", + "senior", + "gender-neutral" + ], + "👴": [ + "old_man", + "human", + "male", + "men", + "old", + "elder", + "senior" + ], + "👵": [ + "old_woman", + "human", + "female", + "women", + "lady", + "old", + "elder", + "senior" + ], + "🙍": [ + "person_frowning", + "worried" + ], + "🙍‍♂️": [ + "man_frowning", + "male", + "boy", + "man", + "sad", + "depressed", + "discouraged", + "unhappy" + ], + "🙍‍♀️": [ + "woman_frowning", + "female", + "girl", + "woman", + "sad", + "depressed", + "discouraged", + "unhappy" + ], + "🙎": [ + "person_pouting", + "upset" + ], + "🙎‍♂️": [ + "man_pouting", + "male", + "boy", + "man" + ], + "🙎‍♀️": [ + "woman_pouting", + "female", + "girl", + "woman" + ], + "🙅": [ + "person_gesturing_no", + "decline" + ], + "🙅‍♂️": [ + "man_gesturing_no", + "male", + "boy", + "man", + "nope" + ], + "🙅‍♀️": [ + "woman_gesturing_no", + "female", + "girl", + "woman", + "nope" + ], + "🙆": [ + "person_gesturing_ok", + "agree" + ], + "🙆‍♂️": [ + "man_gesturing_ok", + "men", + "boy", + "male", + "blue", + "human", + "man" + ], + "🙆‍♀️": [ + "woman_gesturing_ok", + "women", + "girl", + "female", + "pink", + "human", + "woman" + ], + "💁": [ + "person_tipping_hand", + "information" + ], + "💁‍♂️": [ + "man_tipping_hand", + "male", + "boy", + "man", + "human", + "information" + ], + "💁‍♀️": [ + "woman_tipping_hand", + "female", + "girl", + "woman", + "human", + "information" + ], + "🙋": [ + "person_raising_hand", + "question" + ], + "🙋‍♂️": [ + "man_raising_hand", + "male", + "boy", + "man" + ], + "🙋‍♀️": [ + "woman_raising_hand", + "female", + "girl", + "woman" + ], + "🧏": [ + "deaf_person", + "accessibility" + ], + "🧏‍♂️": [ + "deaf_man", + "accessibility" + ], + "🧏‍♀️": [ + "deaf_woman", + "accessibility" + ], + "🙇": [ + "person_bowing", + "respectiful" + ], + "🙇‍♂️": [ + "man_bowing", + "man", + "male", + "boy" + ], + "🙇‍♀️": [ + "woman_bowing", + "woman", + "female", + "girl" + ], + "🤦": [ + "person_facepalming", + "disappointed" + ], + "🤦‍♂️": [ + "man_facepalming", + "man", + "male", + "boy", + "disbelief" + ], + "🤦‍♀️": [ + "woman_facepalming", + "woman", + "female", + "girl", + "disbelief" + ], + "🤷": [ + "person_shrugging", + "regardless" + ], + "🤷‍♂️": [ + "man_shrugging", + "man", + "male", + "boy", + "confused", + "indifferent", + "doubt" + ], + "🤷‍♀️": [ + "woman_shrugging", + "woman", + "female", + "girl", + "confused", + "indifferent", + "doubt" + ], + "🧑‍⚕️": [ + "health_worker", + "hospital" + ], + "👨‍⚕️": [ + "man_health_worker", + "doctor", + "nurse", + "therapist", + "healthcare", + "man", + "human" + ], + "👩‍⚕️": [ + "woman_health_worker", + "doctor", + "nurse", + "therapist", + "healthcare", + "woman", + "human" + ], + "🧑‍🎓": [ + "student", + "learn" + ], + "👨‍🎓": [ + "man_student", + "graduate", + "man", + "human" + ], + "👩‍🎓": [ + "woman_student", + "graduate", + "woman", + "human" + ], + "🧑‍🏫": [ + "teacher", + "professor" + ], + "👨‍🏫": [ + "man_teacher", + "instructor", + "professor", + "man", + "human" + ], + "👩‍🏫": [ + "woman_teacher", + "instructor", + "professor", + "woman", + "human" + ], + "🧑‍⚖️": [ + "judge", + "law" + ], + "👨‍⚖️": [ + "man_judge", + "justice", + "court", + "man", + "human" + ], + "👩‍⚖️": [ + "woman_judge", + "justice", + "court", + "woman", + "human" + ], + "🧑‍🌾": [ + "farmer", + "crops" + ], + "👨‍🌾": [ + "man_farmer", + "rancher", + "gardener", + "man", + "human" + ], + "👩‍🌾": [ + "woman_farmer", + "rancher", + "gardener", + "woman", + "human" + ], + "🧑‍🍳": [ + "cook", + "food", + "kitchen", + "culinary" + ], + "👨‍🍳": [ + "man_cook", + "chef", + "man", + "human" + ], + "👩‍🍳": [ + "woman_cook", + "chef", + "woman", + "human" + ], + "🧑‍🔧": [ + "mechanic", + "worker", + "technician" + ], + "👨‍🔧": [ + "man_mechanic", + "plumber", + "man", + "human", + "wrench" + ], + "👩‍🔧": [ + "woman_mechanic", + "plumber", + "woman", + "human", + "wrench" + ], + "🧑‍🏭": [ + "factory_worker", + "labor" + ], + "👨‍🏭": [ + "man_factory_worker", + "assembly", + "industrial", + "man", + "human" + ], + "👩‍🏭": [ + "woman_factory_worker", + "assembly", + "industrial", + "woman", + "human" + ], + "🧑‍💼": [ + "office_worker", + "business" + ], + "👨‍💼": [ + "man_office_worker", + "business", + "manager", + "man", + "human" + ], + "👩‍💼": [ + "woman_office_worker", + "business", + "manager", + "woman", + "human" + ], + "🧑‍🔬": [ + "scientist", + "chemistry" + ], + "👨‍🔬": [ + "man_scientist", + "biologist", + "chemist", + "engineer", + "physicist", + "man", + "human" + ], + "👩‍🔬": [ + "woman_scientist", + "biologist", + "chemist", + "engineer", + "physicist", + "woman", + "human" + ], + "🧑‍💻": [ + "technologist", + "computer" + ], + "👨‍💻": [ + "man_technologist", + "coder", + "developer", + "engineer", + "programmer", + "software", + "man", + "human", + "laptop", + "computer" + ], + "👩‍💻": [ + "woman_technologist", + "coder", + "developer", + "engineer", + "programmer", + "software", + "woman", + "human", + "laptop", + "computer" + ], + "🧑‍🎤": [ + "singer", + "song", + "artist", + "performer" + ], + "👨‍🎤": [ + "man_singer", + "rockstar", + "entertainer", + "man", + "human" + ], + "👩‍🎤": [ + "woman_singer", + "rockstar", + "entertainer", + "woman", + "human" + ], + "🧑‍🎨": [ + "artist", + "painting", + "draw", + "creativity" + ], + "👨‍🎨": [ + "man_artist", + "painter", + "man", + "human" + ], + "👩‍🎨": [ + "woman_artist", + "painter", + "woman", + "human" + ], + "🧑‍✈️": [ + "pilot", + "fly", + "plane", + "airplane" + ], + "👨‍✈️": [ + "man_pilot", + "aviator", + "plane", + "man", + "human" + ], + "👩‍✈️": [ + "woman_pilot", + "aviator", + "plane", + "woman", + "human" + ], + "🧑‍🚀": [ + "astronaut", + "outerspace" + ], + "👨‍🚀": [ + "man_astronaut", + "space", + "rocket", + "man", + "human" + ], + "👩‍🚀": [ + "woman_astronaut", + "space", + "rocket", + "woman", + "human" + ], + "🧑‍🚒": [ + "firefighter", + "fire" + ], + "👨‍🚒": [ + "man_firefighter", + "fireman", + "man", + "human" + ], + "👩‍🚒": [ + "woman_firefighter", + "fireman", + "woman", + "human" + ], + "👮": [ + "police_officer", + "cop" + ], + "👮‍♂️": [ + "man_police_officer", + "man", + "police", + "law", + "legal", + "enforcement", + "arrest", + "911" + ], + "👮‍♀️": [ + "woman_police_officer", + "woman", + "police", + "law", + "legal", + "enforcement", + "arrest", + "911", + "female" + ], + "🕵️": [ + "detective", + "human", + "spy", + "detective" + ], + "🕵️‍♂️": [ + "man_detective", + "crime" + ], + "🕵️‍♀️": [ + "woman_detective", + "human", + "spy", + "detective", + "female", + "woman" + ], + "💂": [ + "guard", + "protect" + ], + "💂‍♂️": [ + "man_guard", + "uk", + "gb", + "british", + "male", + "guy", + "royal" + ], + "💂‍♀️": [ + "woman_guard", + "uk", + "gb", + "british", + "female", + "royal", + "woman" + ], + "👷": [ + "construction_worker", + "labor", + "build" + ], + "👷‍♂️": [ + "man_construction_worker", + "male", + "human", + "wip", + "guy", + "build", + "construction", + "worker", + "labor" + ], + "👷‍♀️": [ + "woman_construction_worker", + "female", + "human", + "wip", + "build", + "construction", + "worker", + "labor", + "woman" + ], + "🤴": [ + "prince", + "boy", + "man", + "male", + "crown", + "royal", + "king" + ], + "👸": [ + "princess", + "girl", + "woman", + "female", + "blond", + "crown", + "royal", + "queen" + ], + "👳": [ + "person_wearing_turban", + "headdress" + ], + "👳‍♂️": [ + "man_wearing_turban", + "male", + "indian", + "hinduism", + "arabs" + ], + "👳‍♀️": [ + "woman_wearing_turban", + "female", + "indian", + "hinduism", + "arabs", + "woman" + ], + "👲": [ + "man_with_skullcap", + "male", + "boy", + "chinese" + ], + "🧕": [ + "woman_with_headscarf", + "female", + "hijab", + "mantilla", + "tichel" + ], + "🤵": [ + "man_in_tuxedo", + "couple", + "marriage", + "wedding", + "groom" + ], + "👰": [ + "bride_with_veil", + "couple", + "marriage", + "wedding", + "woman", + "bride" + ], + "🤰": [ + "pregnant_woman", + "baby" + ], + "🤱": [ + "breast_feeding", + "nursing", + "baby" + ], + "👼": [ + "baby_angel", + "heaven", + "wings", + "halo" + ], + "🎅": [ + "santa_claus", + "festival", + "man", + "male", + "xmas", + "father christmas" + ], + "🤶": [ + "mrs_claus", + "woman", + "female", + "xmas", + "mother christmas" + ], + "🦸": [ + "superhero", + "marvel" + ], + "🦸‍♂️": [ + "man_superhero", + "man", + "male", + "good", + "hero", + "superpowers" + ], + "🦸‍♀️": [ + "woman_superhero", + "woman", + "female", + "good", + "heroine", + "superpowers" + ], + "🦹": [ + "supervillain", + "marvel" + ], + "🦹‍♂️": [ + "man_supervillain", + "man", + "male", + "evil", + "bad", + "criminal", + "hero", + "superpowers" + ], + "🦹‍♀️": [ + "woman_supervillain", + "woman", + "female", + "evil", + "bad", + "criminal", + "heroine", + "superpowers" + ], + "🧙": [ + "mage", + "magic" + ], + "🧙‍♂️": [ + "man_mage", + "man", + "male", + "mage", + "sorcerer" + ], + "🧙‍♀️": [ + "woman_mage", + "woman", + "female", + "mage", + "witch" + ], + "🧚": [ + "fairy", + "wings", + "magical" + ], + "🧚‍♂️": [ + "man_fairy", + "man", + "male" + ], + "🧚‍♀️": [ + "woman_fairy", + "woman", + "female" + ], + "🧛": [ + "vampire", + "blood", + "twilight" + ], + "🧛‍♂️": [ + "man_vampire", + "man", + "male", + "dracula" + ], + "🧛‍♀️": [ + "woman_vampire", + "woman", + "female" + ], + "🧜": [ + "merperson", + "sea" + ], + "🧜‍♂️": [ + "merman", + "man", + "male", + "triton" + ], + "🧜‍♀️": [ + "mermaid", + "woman", + "female", + "merwoman", + "ariel" + ], + "🧝": [ + "elf", + "magical" + ], + "🧝‍♂️": [ + "man_elf", + "man", + "male" + ], + "🧝‍♀️": [ + "woman_elf", + "woman", + "female" + ], + "🧞": [ + "genie", + "magical", + "wishes" + ], + "🧞‍♂️": [ + "man_genie", + "man", + "male" + ], + "🧞‍♀️": [ + "woman_genie", + "woman", + "female" + ], + "🧟": [ + "zombie", + "dead" + ], + "🧟‍♂️": [ + "man_zombie", + "man", + "male", + "dracula", + "undead", + "walking dead" + ], + "🧟‍♀️": [ + "woman_zombie", + "woman", + "female", + "undead", + "walking dead" + ], + "💆": [ + "person_getting_massage", + "relax" + ], + "💆‍♂️": [ + "man_getting_massage", + "male", + "boy", + "man", + "head" + ], + "💆‍♀️": [ + "woman_getting_massage", + "female", + "girl", + "woman", + "head" + ], + "💇": [ + "person_getting_haircut", + "hairstyle" + ], + "💇‍♂️": [ + "man_getting_haircut", + "male", + "boy", + "man" + ], + "💇‍♀️": [ + "woman_getting_haircut", + "female", + "girl", + "woman" + ], + "🚶": [ + "person_walking" + ], + "🚶‍♂️": [ + "man_walking", + "human", + "feet", + "steps" + ], + "🚶‍♀️": [ + "woman_walking", + "human", + "feet", + "steps", + "woman", + "female" + ], + "🧍": [ + "person_standing", + "still" + ], + "🧍‍♂️": [ + "man_standing", + "still" + ], + "🧍‍♀️": [ + "woman_standing", + "still" + ], + "🧎": [ + "person_kneeling", + "pray", + "respectful" + ], + "🧎‍♂️": [ + "man_kneeling", + "pray", + "respectful" + ], + "🧎‍♀️": [ + "woman_kneeling", + "respectful", + "pray" + ], + "🧑‍🦯": [ + "person_with_probing_cane", + "blind" + ], + "👨‍🦯": [ + "man_with_probing_cane", + "blind" + ], + "👩‍🦯": [ + "woman_with_probing_cane", + "blind" + ], + "🧑‍🦼": [ + "person_in_motorized_wheelchair", + "disability", + "accessibility" + ], + "👨‍🦼": [ + "man_in_motorized_wheelchair", + "disability", + "accessibility" + ], + "👩‍🦼": [ + "woman_in_motorized_wheelchair", + "disability", + "accessibility" + ], + "🧑‍🦽": [ + "person_in_manual_wheelchair", + "disability", + "accessibility" + ], + "👨‍🦽": [ + "man_in_manual_wheelchair", + "disability", + "accessibility" + ], + "👩‍🦽": [ + "woman_in_manual_wheelchair", + "disability", + "accessibility" + ], + "🏃": [ + "person_running" + ], + "🏃‍♂️": [ + "man_running", + "man", + "walking", + "exercise", + "race", + "running" + ], + "🏃‍♀️": [ + "woman_running", + "woman", + "walking", + "exercise", + "race", + "running", + "female" + ], + "💃": [ + "woman_dancing", + "female", + "girl", + "woman", + "fun" + ], + "🕺": [ + "man_dancing", + "male", + "boy", + "fun", + "dancer" + ], + "🕴️": [ + "man_in_suit_levitating", + "suit", + "business", + "levitate", + "hover", + "jump" + ], + "👯": [ + "people_with_bunny_ears", + "perform", + "costume" + ], + "👯‍♂️": [ + "men_with_bunny_ears", + "male", + "bunny", + "men", + "boys" + ], + "👯‍♀️": [ + "women_with_bunny_ears", + "female", + "bunny", + "women", + "girls" + ], + "🧖": [ + "person_in_steamy_room", + "relax", + "spa" + ], + "🧖‍♂️": [ + "man_in_steamy_room", + "male", + "man", + "spa", + "steamroom", + "sauna" + ], + "🧖‍♀️": [ + "woman_in_steamy_room", + "female", + "woman", + "spa", + "steamroom", + "sauna" + ], + "🧗": [ + "person_climbing", + "sport" + ], + "🧗‍♂️": [ + "man_climbing", + "sports", + "hobby", + "man", + "male", + "rock" + ], + "🧗‍♀️": [ + "woman_climbing", + "sports", + "hobby", + "woman", + "female", + "rock" + ], + "🤺": [ + "person_fencing", + "sports", + "fencing", + "sword" + ], + "🏇": [ + "horse_racing", + "animal", + "betting", + "competition", + "gambling", + "luck" + ], + "⛷️": [ + "skier", + "sports", + "winter", + "snow" + ], + "🏂": [ + "snowboarder", + "sports", + "winter" + ], + "🏌️": [ + "person_golfing", + "sports", + "business" + ], + "🏌️‍♂️": [ + "man_golfing", + "sport" + ], + "🏌️‍♀️": [ + "woman_golfing", + "sports", + "business", + "woman", + "female" + ], + "🏄": [ + "person_surfing", + "sport", + "sea" + ], + "🏄‍♂️": [ + "man_surfing", + "sports", + "ocean", + "sea", + "summer", + "beach" + ], + "🏄‍♀️": [ + "woman_surfing", + "sports", + "ocean", + "sea", + "summer", + "beach", + "woman", + "female" + ], + "🚣": [ + "person_rowing_boat", + "sport" + ], + "🚣‍♂️": [ + "man_rowing_boat", + "sports", + "hobby", + "water", + "ship" + ], + "🚣‍♀️": [ + "woman_rowing_boat", + "sports", + "hobby", + "water", + "ship", + "woman", + "female" + ], + "🏊": [ + "person_swimming", + "sport", + "pool" + ], + "🏊‍♂️": [ + "man_swimming", + "sports", + "exercise", + "human", + "athlete", + "water", + "summer" + ], + "🏊‍♀️": [ + "woman_swimming", + "sports", + "exercise", + "human", + "athlete", + "water", + "summer", + "woman", + "female" + ], + "⛹️": [ + "person_bouncing_ball", + "sports", + "human" + ], + "⛹️‍♂️": [ + "man_bouncing_ball", + "sport" + ], + "⛹️‍♀️": [ + "woman_bouncing_ball", + "sports", + "human", + "woman", + "female" + ], + "🏋️": [ + "person_lifting_weights", + "sports", + "training", + "exercise" + ], + "🏋️‍♂️": [ + "man_lifting_weights", + "sport" + ], + "🏋️‍♀️": [ + "woman_lifting_weights", + "sports", + "training", + "exercise", + "woman", + "female" + ], + "🚴": [ + "person_biking", + "sport" + ], + "🚴‍♂️": [ + "man_biking", + "sports", + "bike", + "exercise", + "hipster" + ], + "🚴‍♀️": [ + "woman_biking", + "sports", + "bike", + "exercise", + "hipster", + "woman", + "female" + ], + "🚵": [ + "person_mountain_biking", + "sport" + ], + "🚵‍♂️": [ + "man_mountain_biking", + "transportation", + "sports", + "human", + "race", + "bike" + ], + "🚵‍♀️": [ + "woman_mountain_biking", + "transportation", + "sports", + "human", + "race", + "bike", + "woman", + "female" + ], + "🤸": [ + "person_cartwheeling", + "sport", + "gymnastic" + ], + "🤸‍♂️": [ + "man_cartwheeling", + "gymnastics" + ], + "🤸‍♀️": [ + "woman_cartwheeling", + "gymnastics" + ], + "🤼": [ + "people_wrestling", + "sport" + ], + "🤼‍♂️": [ + "men_wrestling", + "sports", + "wrestlers" + ], + "🤼‍♀️": [ + "women_wrestling", + "sports", + "wrestlers" + ], + "🤽": [ + "person_playing_water_polo", + "sport" + ], + "🤽‍♂️": [ + "man_playing_water_polo", + "sports", + "pool" + ], + "🤽‍♀️": [ + "woman_playing_water_polo", + "sports", + "pool" + ], + "🤾": [ + "person_playing_handball", + "sport" + ], + "🤾‍♂️": [ + "man_playing_handball", + "sports" + ], + "🤾‍♀️": [ + "woman_playing_handball", + "sports" + ], + "🤹": [ + "person_juggling", + "performance", + "balance" + ], + "🤹‍♂️": [ + "man_juggling", + "juggle", + "balance", + "skill", + "multitask" + ], + "🤹‍♀️": [ + "woman_juggling", + "juggle", + "balance", + "skill", + "multitask" + ], + "🧘": [ + "person_in_lotus_position", + "meditate" + ], + "🧘‍♂️": [ + "man_in_lotus_position", + "man", + "male", + "meditation", + "yoga", + "serenity", + "zen", + "mindfulness" + ], + "🧘‍♀️": [ + "woman_in_lotus_position", + "woman", + "female", + "meditation", + "yoga", + "serenity", + "zen", + "mindfulness" + ], + "🛌": [ + "person_in_bed", + "bed", + "rest" + ], + "🧑‍🤝‍🧑": [ + "people_holding_hands", + "friendship" + ], + "👭": [ + "women_holding_hands", + "pair", + "friendship", + "couple", + "love", + "like", + "female", + "people", + "human" + ], + "👫": [ + "woman_and_man_holding_hands", + "pair", + "people", + "human", + "love", + "date", + "dating", + "like", + "affection", + "valentines", + "marriage" + ], + "👬": [ + "men_holding_hands", + "pair", + "couple", + "love", + "like", + "bromance", + "friendship", + "people", + "human" + ], + "💏": [ + "kiss", + "pair", + "valentines", + "love", + "like", + "dating", + "marriage" + ], + "👩‍❤️‍💋‍👨": [ + "kiss_woman_man", + "love" + ], + "👨‍❤️‍💋‍👨": [ + "kiss_man_man", + "pair", + "valentines", + "love", + "like", + "dating", + "marriage" + ], + "👩‍❤️‍💋‍👩": [ + "kiss_woman_woman", + "pair", + "valentines", + "love", + "like", + "dating", + "marriage" + ], + "💑": [ + "couple_with_heart", + "pair", + "love", + "like", + "affection", + "human", + "dating", + "valentines", + "marriage" + ], + "👩‍❤️‍👨": [ + "couple_with_heart_woman_man", + "love" + ], + "👨‍❤️‍👨": [ + "couple_with_heart_man_man", + "pair", + "love", + "like", + "affection", + "human", + "dating", + "valentines", + "marriage" + ], + "👩‍❤️‍👩": [ + "couple_with_heart_woman_woman", + "pair", + "love", + "like", + "affection", + "human", + "dating", + "valentines", + "marriage" + ], + "👪": [ + "family", + "home", + "parents", + "child", + "mom", + "dad", + "father", + "mother", + "people", + "human" + ], + "👨‍👩‍👦": [ + "family_man_woman_boy", + "love" + ], + "👨‍👩‍👧": [ + "family_man_woman_girl", + "home", + "parents", + "people", + "human", + "child" + ], + "👨‍👩‍👧‍👦": [ + "family_man_woman_girl_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👩‍👦‍👦": [ + "family_man_woman_boy_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👩‍👧‍👧": [ + "family_man_woman_girl_girl", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👨‍👦": [ + "family_man_man_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👨‍👧": [ + "family_man_man_girl", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👨‍👧‍👦": [ + "family_man_man_girl_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👨‍👦‍👦": [ + "family_man_man_boy_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👨‍👧‍👧": [ + "family_man_man_girl_girl", + "home", + "parents", + "people", + "human", + "children" + ], + "👩‍👩‍👦": [ + "family_woman_woman_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👩‍👩‍👧": [ + "family_woman_woman_girl", + "home", + "parents", + "people", + "human", + "children" + ], + "👩‍👩‍👧‍👦": [ + "family_woman_woman_girl_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👩‍👩‍👦‍👦": [ + "family_woman_woman_boy_boy", + "home", + "parents", + "people", + "human", + "children" + ], + "👩‍👩‍👧‍👧": [ + "family_woman_woman_girl_girl", + "home", + "parents", + "people", + "human", + "children" + ], + "👨‍👦": [ + "family_man_boy", + "home", + "parent", + "people", + "human", + "child" + ], + "👨‍👦‍👦": [ + "family_man_boy_boy", + "home", + "parent", + "people", + "human", + "children" + ], + "👨‍👧": [ + "family_man_girl", + "home", + "parent", + "people", + "human", + "child" + ], + "👨‍👧‍👦": [ + "family_man_girl_boy", + "home", + "parent", + "people", + "human", + "children" + ], + "👨‍👧‍👧": [ + "family_man_girl_girl", + "home", + "parent", + "people", + "human", + "children" + ], + "👩‍👦": [ + "family_woman_boy", + "home", + "parent", + "people", + "human", + "child" + ], + "👩‍👦‍👦": [ + "family_woman_boy_boy", + "home", + "parent", + "people", + "human", + "children" + ], + "👩‍👧": [ + "family_woman_girl", + "home", + "parent", + "people", + "human", + "child" + ], + "👩‍👧‍👦": [ + "family_woman_girl_boy", + "home", + "parent", + "people", + "human", + "children" + ], + "👩‍👧‍👧": [ + "family_woman_girl_girl", + "home", + "parent", + "people", + "human", + "children" + ], + "🗣️": [ + "speaking_head", + "user", + "person", + "human", + "sing", + "say", + "talk" + ], + "👤": [ + "bust_in_silhouette", + "user", + "person", + "human" + ], + "👥": [ + "busts_in_silhouette", + "user", + "person", + "human", + "group", + "team", + "meet", + "meeting", + "митинг" + ], + "👣": [ + "footprints", + "feet", + "tracking", + "walking", + "beach" + ], + "🐵": [ + "monkey_face", + "animal", + "nature", + "circus" + ], + "🐒": [ + "monkey", + "animal", + "nature", + "banana", + "circus" + ], + "🦍": [ + "gorilla", + "animal", + "nature", + "circus" + ], + "🦧": [ + "orangutan", + "animal" + ], + "🐶": [ + "dog_face", + "animal", + "friend", + "nature", + "woof", + "puppy", + "pet", + "faithful" + ], + "🐕": [ + "dog", + "animal", + "nature", + "friend", + "doge", + "pet", + "faithful" + ], + "🦮": [ + "guide_dog", + "animal", + "blind" + ], + "🐕‍🦺": [ + "service_dog", + "blind", + "animal" + ], + "🐩": [ + "poodle", + "dog", + "animal", + "101", + "nature", + "pet" + ], + "🐺": [ + "wolf", + "animal", + "nature", + "wild" + ], + "🦊": [ + "fox", + "animal", + "nature", + "face" + ], + "🦝": [ + "raccoon", + "animal", + "nature" + ], + "🐱": [ + "cat_face", + "animal", + "meow", + "nature", + "pet", + "kitten" + ], + "🐈": [ + "cat", + "animal", + "meow", + "pet", + "cats" + ], + "🦁": [ + "lion", + "animal", + "nature" + ], + "🐯": [ + "tiger_face", + "animal", + "cat", + "danger", + "wild", + "nature", + "roar" + ], + "🐅": [ + "tiger", + "animal", + "nature", + "roar" + ], + "🐆": [ + "leopard", + "animal", + "nature" + ], + "🐴": [ + "horse_face", + "animal", + "brown", + "nature" + ], + "🐎": [ + "horse", + "animal", + "gamble", + "luck" + ], + "🦄": [ + "unicorn", + "animal", + "nature", + "mystical", + "management" + ], + "🦓": [ + "zebra", + "animal", + "nature", + "stripes", + "safari" + ], + "🦌": [ + "deer", + "animal", + "nature", + "horns", + "venison" + ], + "🐮": [ + "cow_face", + "beef", + "ox", + "animal", + "nature", + "moo", + "milk", + "молоко" + ], + "🐂": [ + "ox", + "animal", + "cow", + "beef" + ], + "🐃": [ + "water_buffalo", + "animal", + "nature", + "ox", + "cow" + ], + "🐄": [ + "cow", + "beef", + "ox", + "animal", + "nature", + "moo", + "milk" + ], + "🐷": [ + "pig_face", + "animal", + "oink", + "nature" + ], + "🐖": [ + "pig", + "animal", + "nature" + ], + "🐗": [ + "boar", + "animal", + "nature" + ], + "🐽": [ + "pig_nose", + "animal", + "oink" + ], + "🐏": [ + "ram", + "animal", + "sheep", + "nature" + ], + "🐑": [ + "ewe", + "animal", + "nature", + "wool", + "shipit" + ], + "🐐": [ + "goat", + "animal", + "nature" + ], + "🐪": [ + "camel", + "animal", + "hot", + "desert", + "hump" + ], + "🐫": [ + "two_hump_camel", + "animal", + "nature", + "hot", + "desert", + "hump" + ], + "🦙": [ + "llama", + "animal", + "nature", + "alpaca" + ], + "🦒": [ + "giraffe", + "animal", + "nature", + "spots", + "safari" + ], + "🐘": [ + "elephant", + "animal", + "nature", + "nose", + "th", + "circus", + "php" + ], + "🦏": [ + "rhinoceros", + "animal", + "nature", + "horn" + ], + "🦛": [ + "hippopotamus", + "animal", + "nature" + ], + "🐭": [ + "mouse_face", + "animal", + "nature", + "cheese_wedge", + "rodent" + ], + "🐁": [ + "mouse", + "animal", + "nature", + "rodent" + ], + "🐀": [ + "rat", + "animal", + "mouse", + "rodent" + ], + "🐹": [ + "hamster", + "animal", + "nature" + ], + "🐰": [ + "rabbit_face", + "animal", + "nature", + "pet", + "spring", + "magic", + "bunny" + ], + "🐇": [ + "rabbit", + "animal", + "nature", + "pet", + "magic", + "spring" + ], + "🐿️": [ + "chipmunk", + "animal", + "nature", + "rodent", + "squirrel" + ], + "🦔": [ + "hedgehog", + "animal", + "nature", + "spiny" + ], + "🦇": [ + "bat", + "animal", + "nature", + "blind", + "vampire" + ], + "🐻": [ + "bear", + "animal", + "nature", + "wild" + ], + "🐨": [ + "koala", + "animal", + "nature" + ], + "🐼": [ + "panda", + "animal", + "nature", + "panda" + ], + "🦥": [ + "sloth", + "animal", + "postpone" + ], + "🦦": [ + "otter", + "animal" + ], + "🦨": [ + "skunk", + "animal" + ], + "🦘": [ + "kangaroo", + "animal", + "nature", + "australia", + "joey", + "hop", + "marsupial" + ], + "🦡": [ + "badger", + "animal", + "nature", + "honey" + ], + "🐾": [ + "paw_prints", + "animal", + "tracking", + "footprints", + "dog", + "cat", + "pet", + "feet" + ], + "🦃": [ + "turkey", + "animal", + "bird" + ], + "🐔": [ + "chicken", + "animal", + "cluck", + "nature", + "bird" + ], + "🐓": [ + "rooster", + "animal", + "nature", + "chicken" + ], + "🐣": [ + "hatching_chick", + "animal", + "chicken", + "egg", + "born", + "baby", + "bird" + ], + "🐤": [ + "baby_chick", + "animal", + "chicken", + "bird" + ], + "🐥": [ + "front_facing_baby_chick", + "animal", + "chicken", + "baby", + "bird" + ], + "🐦": [ + "bird", + "animal", + "nature", + "fly", + "tweet", + "spring" + ], + "🐧": [ + "penguin", + "animal", + "nature" + ], + "🕊️": [ + "dove", + "animal", + "bird" + ], + "🦅": [ + "eagle", + "animal", + "nature", + "bird" + ], + "🦆": [ + "duck", + "animal", + "nature", + "bird", + "mallard" + ], + "🦢": [ + "swan", + "animal", + "nature", + "bird" + ], + "🦉": [ + "owl", + "animal", + "nature", + "bird", + "hoot" + ], + "🦩": [ + "flamingo", + "animal" + ], + "🦚": [ + "peacock", + "animal", + "nature", + "peahen", + "bird" + ], + "🦜": [ + "parrot", + "animal", + "nature", + "bird", + "pirate", + "talk" + ], + "🐸": [ + "frog", + "animal", + "nature", + "croak", + "toad" + ], + "🐊": [ + "crocodile", + "animal", + "nature", + "reptile", + "lizard", + "alligator" + ], + "🐢": [ + "turtle", + "animal", + "slow", + "nature", + "tortoise" + ], + "🦎": [ + "lizard", + "animal", + "nature", + "reptile" + ], + "🐍": [ + "snake", + "animal", + "evil", + "nature", + "hiss", + "python" + ], + "🐲": [ + "dragon_face", + "animal", + "myth", + "nature", + "chinese", + "green" + ], + "🐉": [ + "dragon", + "animal", + "myth", + "nature", + "chinese", + "green" + ], + "🦕": [ + "sauropod", + "animal", + "nature", + "dinosaur", + "brachiosaurus", + "brontosaurus", + "diplodocus", + "extinct" + ], + "🦖": [ + "t_rex", + "animal", + "nature", + "dinosaur", + "tyrannosaurus", + "extinct" + ], + "🐳": [ + "spouting_whale", + "animal", + "nature", + "sea", + "ocean" + ], + "🐋": [ + "whale", + "animal", + "nature", + "sea", + "ocean" + ], + "🐬": [ + "dolphin", + "animal", + "nature", + "fish", + "sea", + "ocean", + "flipper", + "fins", + "beach" + ], + "🐟": [ + "fish", + "animal", + "food", + "nature" + ], + "🐠": [ + "tropical_fish", + "animal", + "swim", + "ocean", + "beach", + "nemo" + ], + "🐡": [ + "blowfish", + "animal", + "nature", + "food", + "sea", + "ocean" + ], + "🦈": [ + "shark", + "animal", + "nature", + "fish", + "sea", + "ocean", + "jaws", + "fins", + "beach" + ], + "🐙": [ + "octopus", + "animal", + "creature", + "ocean", + "sea", + "nature", + "beach" + ], + "🐚": [ + "spiral_shell", + "nature", + "sea", + "beach" + ], + "🐌": [ + "snail", + "slow", + "animal", + "shell" + ], + "🦋": [ + "butterfly", + "animal", + "insect", + "nature", + "caterpillar" + ], + "🐛": [ + "bug", + "animal", + "insect", + "nature", + "worm" + ], + "🐜": [ + "ant", + "animal", + "insect", + "nature", + "bug" + ], + "🐝": [ + "honeybee", + "animal", + "insect", + "nature", + "bug", + "spring", + "honey" + ], + "🐞": [ + "lady_beetle", + "animal", + "insect", + "nature", + "ladybug" + ], + "🦗": [ + "cricket", + "animal", + "cricket", + "chirp" + ], + "🕷️": [ + "spider", + "animal", + "arachnid" + ], + "🕸️": [ + "spider_web", + "animal", + "insect", + "arachnid", + "silk" + ], + "🦂": [ + "scorpion", + "animal", + "arachnid" + ], + "🦟": [ + "mosquito", + "animal", + "nature", + "insect", + "malaria" + ], + "🦠": [ + "microbe", + "amoeba", + "bacteria", + "germs", + "virus", + "covid" + ], + "💐": [ + "bouquet", + "flowers", + "nature", + "spring" + ], + "🌸": [ + "cherry_blossom", + "nature", + "plant", + "spring", + "flower" + ], + "💮": [ + "white_flower", + "japanese", + "spring" + ], + "🏵️": [ + "rosette", + "flower", + "decoration", + "military" + ], + "🌹": [ + "rose", + "flowers", + "valentines", + "love", + "spring" + ], + "🥀": [ + "wilted_flower", + "plant", + "nature", + "flower", + "rose" + ], + "🌺": [ + "hibiscus", + "plant", + "vegetable", + "flowers", + "beach" + ], + "🌻": [ + "sunflower", + "nature", + "plant", + "fall" + ], + "🌼": [ + "blossom", + "nature", + "flowers", + "yellow" + ], + "🌷": [ + "tulip", + "flowers", + "plant", + "nature", + "summer", + "spring" + ], + "🌱": [ + "four_leaf_clover", + "vegetable", + "plant", + "nature", + "lucky", + "irish", + "habits" + ], + "🌲": [ + "evergreen_tree", + "plant", + "nature" + ], + "🌳": [ + "deciduous_tree", + "plant", + "nature" + ], + "🌴": [ + "palm_tree", + "palm", + "plant", + "vegetable", + "nature", + "summer", + "beach", + "mojito", + "tropical" + ], + "🌵": [ + "cactus", + "vegetable", + "plant", + "nature" + ], + "🌾": [ + "sheaf_of_rice", + "nature", + "plant" + ], + "🌿": [ + "herb", + "vegetable", + "plant", + "medicine", + "weed", + "grass", + "lawn" + ], + "☘️": [ + "shamrock", + "vegetable", + "plant", + "nature", + "irish", + "clover" + ], + "🍁": [ + "maple_leaf", + "nature", + "plant", + "vegetable", + "ca", + "fall" + ], + "🍂": [ + "fallen_leaf", + "nature", + "plant", + "vegetable", + "leaves" + ], + "🍃": [ + "leaf_fluttering_in_wind", + "nature", + "plant", + "tree", + "vegetable", + "grass", + "lawn", + "spring" + ], + "🍇": [ + "grapes", + "fruit", + "food", + "wine" + ], + "🍈": [ + "melon", + "fruit", + "nature", + "food" + ], + "🍉": [ + "watermelon", + "fruit", + "food", + "picnic", + "summer" + ], + "🍊": [ + "tangerine", + "food", + "fruit", + "nature", + "orange" + ], + "🍋": [ + "lemon", + "fruit", + "nature" + ], + "🍌": [ + "banana", + "fruit", + "food", + "monkey" + ], + "🍍": [ + "pineapple", + "fruit", + "nature", + "food" + ], + "🥭": [ + "mango", + "fruit", + "food", + "tropical" + ], + "🍎": [ + "red_apple", + "fruit", + "mac", + "school" + ], + "🍏": [ + "green_apple", + "fruit", + "nature", + "apple" + ], + "🍐": [ + "pear", + "fruit", + "nature", + "food" + ], + "🍑": [ + "peach", + "fruit", + "nature", + "food" + ], + "🍒": [ + "cherries", + "food", + "fruit" + ], + "🍓": [ + "strawberry", + "fruit", + "food", + "nature" + ], + "🥝": [ + "kiwi_fruit", + "fruit", + "food" + ], + "🍅": [ + "tomato", + "fruit", + "vegetable", + "nature", + "food", + "finished a break" + ], + "🥥": [ + "coconut", + "fruit", + "nature", + "food" + ], + "🥑": [ + "avocado", + "fruit", + "food" + ], + "🍆": [ + "eggplant", + "vegetable", + "nature", + "food", + "aubergine" + ], + "🥔": [ + "potato", + "food", + "tuber", + "vegatable", + "starch" + ], + "🥕": [ + "carrot", + "vegetable", + "food", + "orange" + ], + "🌽": [ + "ear_of_corn", + "food", + "vegetable", + "plant" + ], + "🌶️": [ + "hot_pepper", + "food", + "spicy", + "chilli", + "chili" + ], + "🥒": [ + "cucumber", + "fruit", + "food", + "pickle" + ], + "🥬": [ + "leafy_green", + "food", + "vegetable", + "plant", + "bok choy", + "cabbage", + "kale", + "lettuce" + ], + "🥦": [ + "broccoli", + "fruit", + "food", + "vegetable" + ], + "🧄": [ + "garlic", + "food", + "spice", + "cook" + ], + "🧅": [ + "onion", + "cook", + "food", + "spice" + ], + "🍄": [ + "mushroom", + "plant", + "vegetable" + ], + "🥜": [ + "peanuts", + "food", + "nut" + ], + "🌰": [ + "chestnut", + "food", + "squirrel" + ], + "🍞": [ + "bread", + "food", + "wheat", + "breakfast", + "toast" + ], + "🥐": [ + "croissant", + "food", + "bread", + "french" + ], + "🥖": [ + "baguette_bread", + "food", + "bread", + "french", + "france", + "bakery" + ], + "🥨": [ + "pretzel", + "food", + "bread", + "twisted", + "germany", + "bakery" + ], + "🥯": [ + "bagel", + "food", + "bread", + "bakery", + "schmear", + "jewish", + "bakery" + ], + "🥞": [ + "pancakes", + "food", + "breakfast", + "flapjacks", + "hotcakes", + "brunch" + ], + "🧇": [ + "waffle", + "food", + "breakfast", + "brunch" + ], + "🧀": [ + "cheese_wedge", + "food", + "chadder", + "swiss" + ], + "🍖": [ + "meat_on_bone", + "good", + "food", + "drumstick" + ], + "🍗": [ + "poultry_leg", + "food", + "meat", + "drumstick", + "bird", + "chicken", + "turkey" + ], + "🥩": [ + "cut_of_meat", + "food", + "cow", + "meat", + "cut", + "chop", + "lambchop", + "porkchop" + ], + "🥓": [ + "bacon", + "food", + "breakfast", + "pork", + "pig", + "meat", + "brunch" + ], + "🍔": [ + "hamburger", + "meat", + "fast food", + "beef", + "cheeseburger", + "mcdonalds", + "burger king" + ], + "🍟": [ + "french_fries", + "chips", + "snack", + "fast food", + "potato" + ], + "🍕": [ + "pizza", + "food", + "party", + "italy" + ], + "🌭": [ + "hot_dog", + "food", + "frankfurter", + "america" + ], + "🥪": [ + "sandwich", + "food", + "lunch", + "bread", + "toast", + "bakery" + ], + "🌮": [ + "taco", + "food", + "mexican" + ], + "🌯": [ + "burrito", + "food", + "mexican" + ], + "🥙": [ + "stuffed_flatbread", + "food", + "flatbread", + "stuffed", + "gyro", + "mediterranean" + ], + "🧆": [ + "falafel", + "food", + "mediterranean" + ], + "🥚": [ + "egg", + "food", + "chicken", + "breakfast" + ], + "🍳": [ + "cooking", + "food", + "breakfast", + "kitchen", + "egg", + "skillet" + ], + "🥘": [ + "shallow_pan_of_food", + "food", + "cooking", + "casserole", + "paella", + "skillet" + ], + "🍲": [ + "pot_of_food", + "food", + "meat", + "soup", + "hot pot" + ], + "🥣": [ + "bowl_with_spoon", + "food", + "breakfast", + "cereal", + "oatmeal", + "porridge" + ], + "🥗": [ + "green_salad", + "food", + "healthy", + "lettuce", + "vegetable" + ], + "📺": [ + "television", + "technology", + "program", + "oldschool", + "show", + "television", + "watch", + "watchlist", + "to watch" + ], + "🧈": [ + "butter", + "food", + "cook" + ], + "🧂": [ + "salt", + "condiment", + "shaker" + ], + "🥫": [ + "canned_food", + "food", + "soup", + "tomatoes" + ], + "🍱": [ + "bento_box", + "food", + "japanese", + "box", + "lunch" + ], + "🍘": [ + "rice_cracker", + "food", + "japanese", + "snack" + ], + "🍙": [ + "rice_ball", + "food", + "japanese" + ], + "🍚": [ + "cooked_rice", + "food", + "asian" + ], + "🍛": [ + "curry_rice", + "food", + "spicy", + "hot", + "indian" + ], + "🍜": [ + "steaming_bowl", + "food", + "japanese", + "noodle", + "chopsticks", + "ramen" + ], + "🍝": [ + "spaghetti", + "food", + "italian", + "pasta", + "noodle" + ], + "🍠": [ + "roasted_sweet_potato", + "food", + "nature", + "plant" + ], + "🍢": [ + "oden", + "food", + "japanese" + ], + "🍣": [ + "sushi", + "food", + "fish", + "japanese", + "rice" + ], + "🍤": [ + "fried_shrimp", + "food", + "animal", + "appetizer", + "summer" + ], + "🍥": [ + "fish_cake_with_swirl", + "food", + "japan", + "sea", + "beach", + "narutomaki", + "pink", + "swirl", + "kamaboko", + "surimi", + "ramen" + ], + "🥮": [ + "moon_cake", + "food", + "autumn", + "dessert" + ], + "🍡": [ + "dango", + "food", + "dessert", + "sweet", + "japanese", + "barbecue", + "meat" + ], + "🥟": [ + "dumpling", + "food", + "empanada", + "pierogi", + "potsticker", + "gyoza" + ], + "🥠": [ + "fortune_cookie", + "food", + "prophecy", + "dessert" + ], + "🥡": [ + "takeout_box", + "food", + "leftovers" + ], + "🦀": [ + "crab", + "animal", + "crustacean" + ], + "🦞": [ + "lobster", + "animal", + "nature", + "bisque", + "claws", + "seafood" + ], + "🦐": [ + "shrimp", + "animal", + "ocean", + "nature", + "seafood" + ], + "🦑": [ + "squid", + "animal", + "nature", + "ocean", + "sea" + ], + "🦪": [ + "oyster", + "food" + ], + "🍦": [ + "soft_ice_cream", + "food", + "hot", + "dessert", + "summer" + ], + "🍧": [ + "shaved_ice", + "hot", + "dessert", + "summer" + ], + "🍨": [ + "ice_cream", + "food", + "hot", + "dessert" + ], + "🍩": [ + "doughnut", + "food", + "dessert", + "snack", + "sweet", + "donut" + ], + "🍪": [ + "cookie", + "food", + "snack", + "oreo", + "chocolate", + "sweet", + "dessert" + ], + "🎂": [ + "birthday_cake", + "food", + "dessert", + "cake" + ], + "🍰": [ + "shortcake", + "food", + "dessert" + ], + "🧁": [ + "cupcake", + "food", + "dessert", + "bakery", + "sweet" + ], + "🥧": [ + "pie", + "food", + "dessert", + "pastry" + ], + "🍫": [ + "chocolate_bar", + "food", + "snack", + "dessert", + "sweet" + ], + "🍬": [ + "candy", + "snack", + "dessert", + "sweet", + "lolly" + ], + "🍭": [ + "lollipop", + "food", + "snack", + "candy", + "sweet" + ], + "🍮": [ + "custard", + "dessert", + "food" + ], + "🍯": [ + "honey_pot", + "bees", + "sweet", + "kitchen" + ], + "🍼": [ + "baby_bottle", + "food", + "container", + "milk" + ], + "🥛": [ + "glass_of_milk", + "beverage", + "drink", + "cow" + ], + "☕": [ + "hot_beverage", + "beverage", + "caffeine", + "latte", + "espresso", + "coffee", + "mug" + ], + "🍵": [ + "teacup_without_handle", + "drink", + "bowl", + "breakfast", + "green", + "british" + ], + "🍶": [ + "sake", + "wine", + "drink", + "drunk", + "beverage", + "japanese", + "alcohol", + "booze" + ], + "🍾": [ + "bottle_with_popping_cork", + "drink", + "wine", + "bottle", + "celebration" + ], + "🍷": [ + "wine_glass", + "drink", + "beverage", + "drunk", + "alcohol", + "booze" + ], + "🍸": [ + "cocktail_glass", + "drink", + "drunk", + "alcohol", + "beverage", + "booze", + "mojito" + ], + "🍹": [ + "tropical_drink", + "beverage", + "cocktail", + "summer", + "beach", + "alcohol", + "booze", + "mojito" + ], + "🍺": [ + "beer_mug", + "relax", + "beverage", + "drink", + "drunk", + "party", + "pub", + "summer", + "alcohol", + "booze" + ], + "🍻": [ + "clinking_beer_mugs", + "relax", + "beverage", + "drink", + "drunk", + "party", + "pub", + "summer", + "alcohol", + "booze" + ], + "🥂": [ + "clinking_glasses", + "beverage", + "drink", + "party", + "alcohol", + "celebrate", + "cheers", + "wine", + "champagne", + "toast" + ], + "🥃": [ + "tumbler_glass", + "drink", + "beverage", + "drunk", + "alcohol", + "liquor", + "booze", + "bourbon", + "scotch", + "whisky", + "glass", + "shot" + ], + "🥤": [ + "cup_with_straw", + "drink", + "soda" + ], + "🧃": [ + "beverage_box", + "drink" + ], + "🧉": [ + "mate", + "drink", + "tea", + "beverage" + ], + "🧊": [ + "ice", + "water", + "cold" + ], + "🥢": [ + "chopsticks", + "food" + ], + "🍽️": [ + "fork_and_knife_with_plate", + "food", + "eat", + "meal", + "lunch", + "dinner", + "restaurant" + ], + "🍴": [ + "fork_and_knife", + "cutlery", + "kitchen" + ], + "🥄": [ + "spoon", + "cutlery", + "kitchen", + "tableware" + ], + "🔪": [ + "kitchen_knife", + "knife", + "blade", + "cutlery", + "kitchen", + "weapon" + ], + "🏺": [ + "amphora", + "vase", + "jar" + ], + "🌍": [ + "globe_showing_europe_africa", + "globe", + "international" + ], + "🌎": [ + "globe_showing_americas", + "globe", + "world", + "USA", + "international", + "places" + ], + "🌏": [ + "globe_showing_asia_australia", + "globe", + "east", + "international" + ], + "🌐": [ + "globe_with_meridians", + "earth", + "international", + "internet", + "interweb", + "i18n" + ], + "🗺️": [ + "world_map", + "location", + "direction" + ], + "🗾": [ + "map_of_japan", + "nation", + "country", + "japanese", + "asia" + ], + "🧭": [ + "compass", + "magnetic", + "navigation", + "orienteering" + ], + "🏔️": [ + "snow_capped_mountain", + "nature", + "environment", + "winter", + "cold" + ], + "⛰️": [ + "mountain", + "nature", + "environment" + ], + "🌋": [ + "volcano", + "nature", + "disaster" + ], + "🗻": [ + "mount_fuji", + "mountain", + "nature", + "japanese" + ], + "🏕️": [ + "camping", + "photo", + "outdoors", + "tent" + ], + "🏖️": [ + "beach_with_umbrella", + "weather", + "summer", + "sunny", + "sand", + "mojito" + ], + "🏜️": [ + "desert", + "warm", + "saharah" + ], + "🏝️": [ + "desert_island", + "tropical", + "mojito" + ], + "🏞️": [ + "national_park", + "photo", + "environment", + "nature" + ], + "🏟️": [ + "stadium", + "photo", + "place", + "sports", + "concert", + "venue" + ], + "🏛️": [ + "classical_building", + "art", + "culture", + "history" + ], + "🏗️": [ + "building_construction", + "wip", + "working", + "progress" + ], + "🧱": [ + "brick", + "bricks" + ], + "🏘️": [ + "houses", + "buildings", + "photo" + ], + "🏚️": [ + "derelict_house", + "abandon", + "evict", + "broken", + "building" + ], + "🏠": [ + "fml", + "house", + "building", + "home" + ], + "🏡": [ + "house_with_garden", + "home", + "plant", + "nature" + ], + "🏢": [ + "office_building", + "building", + "bureau", + "work" + ], + "🏣": [ + "japanese_post_office", + "building", + "envelope" + ], + "🏤": [ + "post_office", + "building", + "email" + ], + "🏥": [ + "hospital", + "building", + "health", + "surgery", + "doctor" + ], + "🏦": [ + "bank", + "building", + "money", + "sales", + "cash", + "business", + "enterprise" + ], + "🏨": [ + "hotel", + "building", + "accomodation", + "checkin" + ], + "🏩": [ + "love_hotel", + "like", + "affection", + "dating" + ], + "🏪": [ + "convenience_store", + "building", + "shopping", + "groceries" + ], + "🏫": [ + "school", + "building", + "student", + "education", + "learn", + "teach" + ], + "🏬": [ + "department_store", + "building", + "shopping", + "mall" + ], + "🏭": [ + "factory", + "building", + "industry", + "pollution", + "smoke" + ], + "🏯": [ + "japanese_castle", + "photo", + "building" + ], + "🏰": [ + "castle", + "building", + "royalty", + "history" + ], + "💒": [ + "wedding", + "love", + "like", + "affection", + "couple", + "marriage", + "bride", + "groom" + ], + "🗼": [ + "tokyo_tower", + "photo", + "japanese" + ], + "🗽": [ + "statue_of_liberty", + "american", + "newyork" + ], + "⛪": [ + "church", + "building", + "religion", + "christ" + ], + "🕌": [ + "mosque", + "islam", + "worship", + "minaret", + "arch" + ], + "🛕": [ + "hindu_temple", + "religion" + ], + "🕍": [ + "synagogue", + "judaism", + "worship", + "temple", + "jewish" + ], + "⛩️": [ + "shinto_shrine", + "temple", + "japan", + "kyoto" + ], + "🕋": [ + "kaaba", + "mecca", + "mosque", + "islam" + ], + "⛲": [ + "fountain", + "photo", + "summer", + "water", + "fresh" + ], + "⛺": [ + "tent", + "photo", + "camping", + "outdoors" + ], + "🌁": [ + "foggy", + "photo", + "mountain" + ], + "🌃": [ + "night_with_stars", + "evening", + "city", + "downtown" + ], + "🏙️": [ + "cityscape", + "photo", + "night life", + "urban" + ], + "🌄": [ + "sunrise_over_mountains", + "view", + "vacation", + "photo" + ], + "🌅": [ + "sunrise", + "morning", + "view", + "vacation", + "photo" + ], + "🌆": [ + "cityscape_at_dusk", + "photo", + "evening", + "sky", + "buildings" + ], + "🌇": [ + "sunset", + "photo", + "good morning", + "dawn" + ], + "🌉": [ + "bridge_at_night", + "photo", + "sanfrancisco" + ], + "♨️": [ + "hot_springs", + "bath", + "warm", + "relax" + ], + "🎠": [ + "carousel_horse", + "photo", + "carnival" + ], + "🎡": [ + "ferris_wheel", + "photo", + "carnival", + "londoneye" + ], + "🎢": [ + "roller_coaster", + "carnival", + "playground", + "photo", + "fun" + ], + "💈": [ + "barber_pole", + "hair", + "salon", + "style" + ], + "🎪": [ + "circus_tent", + "festival", + "carnival", + "party" + ], + "🚂": [ + "locomotive", + "transportation", + "vehicle", + "train" + ], + "🚃": [ + "railway_car", + "transportation", + "vehicle" + ], + "🚄": [ + "high_speed_train", + "transportation", + "vehicle" + ], + "🚅": [ + "bullet_train", + "transportation", + "vehicle", + "speed", + "fast", + "public" + ], + "🚆": [ + "train", + "transportation", + "vehicle" + ], + "🚇": [ + "metro", + "transportation", + "blue-square", + "mrt", + "underground", + "tube" + ], + "🚈": [ + "light_rail", + "transportation", + "vehicle" + ], + "🚉": [ + "station", + "transportation", + "vehicle", + "public" + ], + "🚊": [ + "tram", + "transportation", + "vehicle" + ], + "🚝": [ + "monorail", + "transportation", + "vehicle" + ], + "🚞": [ + "mountain_railway", + "transportation", + "vehicle", + "trip" + ], + "🚋": [ + "tram_car", + "transportation", + "vehicle", + "carriage", + "public", + "travel" + ], + "🚌": [ + "bus", + "car", + "vehicle", + "transportation" + ], + "🚍": [ + "oncoming_bus", + "vehicle", + "transportation" + ], + "🚎": [ + "trolleybus", + "bart", + "transportation", + "vehicle" + ], + "🚐": [ + "minibus", + "vehicle", + "car", + "transportation" + ], + "🚑": [ + "ambulance", + "health", + "911", + "hospital" + ], + "🚒": [ + "fire_engine", + "transportation", + "cars", + "vehicle" + ], + "🚓": [ + "police_car", + "vehicle", + "cars", + "transportation", + "law", + "legal", + "enforcement" + ], + "🚔": [ + "oncoming_police_car", + "vehicle", + "law", + "legal", + "enforcement", + "911" + ], + "🚕": [ + "taxi", + "uber", + "vehicle", + "cars", + "transportation" + ], + "🚖": [ + "oncoming_taxi", + "vehicle", + "cars", + "uber" + ], + "🚗": [ + "automobile", + "red", + "transportation", + "vehicle" + ], + "🚘": [ + "oncoming_automobile", + "car", + "vehicle", + "transportation", + "авто", + "машина", + "машину" + ], + "🚙": [ + "sport_utility_vehicle", + "transportation", + "vehicle" + ], + "🚚": [ + "delivery_truck", + "cars", + "transportation" + ], + "🚛": [ + "articulated_lorry", + "vehicle", + "cars", + "transportation", + "express" + ], + "🚜": [ + "tractor", + "vehicle", + "car", + "farming", + "agriculture" + ], + "🏎️": [ + "racing_car", + "sports", + "race", + "fast", + "formula", + "f1" + ], + "🏍️": [ + "motorcycle", + "race", + "sports", + "fast" + ], + "🛵": [ + "motor_scooter", + "vehicle", + "vespa", + "sasha" + ], + "🦽": [ + "manual_wheelchair", + "accessibility" + ], + "🦼": [ + "motorized_wheelchair", + "accessibility" + ], + "🛺": [ + "auto_rickshaw", + "transportation" + ], + "🚲": [ + "bicycle", + "sports", + "bicycle", + "exercise", + "hipster" + ], + "🛴": [ + "kick_scooter", + "vehicle", + "kick", + "razor" + ], + "🛹": [ + "skateboard", + "board" + ], + "🚏": [ + "bus_stop", + "transportation", + "wait" + ], + "🛣️": [ + "motorway", + "road", + "cupertino", + "interstate", + "highway" + ], + "🛤️": [ + "railway_track", + "train", + "transportation" + ], + "🛢️": [ + "oil_drum", + "barrell" + ], + "⛽": [ + "fuel_pump", + "gas station", + "petroleum" + ], + "🚨": [ + "police_car_light", + "police", + "ambulance", + "911", + "emergency", + "error", + "pinged", + "law", + "legal" + ], + "🚥": [ + "horizontal_traffic_light", + "transportation", + "signal" + ], + "🚦": [ + "vertical_traffic_light", + "transportation", + "driving" + ], + "🛑": [ + "stop_sign", + "stop" + ], + "🚧": [ + "construction", + "wip", + "progress", + "caution", + "warning" + ], + "⚓": [ + "anchor", + "ship", + "ferry", + "sea", + "boat" + ], + "⛵": [ + "sailboat", + "ship", + "summer", + "transportation", + "water", + "sailing" + ], + "🛶": [ + "canoe", + "boat", + "paddle", + "water", + "ship" + ], + "🚤": [ + "speedboat", + "ship", + "transportation", + "vehicle", + "summer" + ], + "🛳️": [ + "passenger_ship", + "yacht", + "cruise", + "ferry" + ], + "⛴️": [ + "ferry", + "boat", + "ship", + "yacht" + ], + "🛥️": [ + "motor_boat", + "ship" + ], + "🚢": [ + "ship", + "transportation", + "titanic", + "deploy" + ], + "✈️": [ + "airplane", + "vehicle", + "transportation", + "flight", + "fly" + ], + "🛩️": [ + "small_airplane", + "flight", + "transportation", + "fly", + "vehicle" + ], + "🛫": [ + "airplane_departure", + "airport", + "flight", + "landing" + ], + "🛬": [ + "airplane_arrival", + "airport", + "flight", + "boarding" + ], + "🪂": [ + "parachute", + "fly", + "glide" + ], + "💺": [ + "seat", + "sit", + "airplane", + "transport", + "bus", + "flight", + "fly" + ], + "🚁": [ + "helicopter", + "transportation", + "vehicle", + "fly" + ], + "🚟": [ + "suspension_railway", + "vehicle", + "transportation" + ], + "🚠": [ + "mountain_cableway", + "transportation", + "vehicle", + "ski" + ], + "🚡": [ + "aerial_tramway", + "transportation", + "vehicle", + "ski" + ], + "🛰️": [ + "satellite", + "communication", + "gps", + "orbit", + "spaceflight", + "NASA", + "ISS" + ], + "🚀": [ + "rocket", + "launch", + "ship", + "staffmode", + "NASA", + "outer space", + "outer_space", + "fly" + ], + "🛸": [ + "flying_saucer", + "transportation", + "vehicle", + "ufo" + ], + "🛎️": [ + "bellhop_bell", + "service" + ], + "🧳": [ + "luggage", + "packing", + "travel" + ], + "⌛": [ + "hourglass_done", + "time", + "clock", + "oldschool", + "limit", + "exam", + "quiz" + ], + "⏳": [ + "hourglass_not_done", + "oldschool", + "time", + "later", + "move to later", + "countdown" + ], + "⌚": [ + "time", + "accessories" + ], + "⏰": [ + "alarm_clock", + "time", + "wake" + ], + "⏱️": [ + "stopwatch", + "time", + "deadline" + ], + "⏲️": [ + "timer_clock", + "alarm" + ], + "🕰️": [ + "mantelpiece_clock", + "time" + ], + "🕛": [ + "twelve_o_clock", + "time", + "noon", + "midnight", + "midday", + "late", + "early" + ], + "📅": [ + "calendar", + "calendar", + "reschedule" + ], + "🕧": [ + "twelve_thirty", + "time", + "late", + "early" + ], + "🕐": [ + "one_o_clock", + "time", + "late", + "early" + ], + "🕜": [ + "one_thirty", + "time", + "late", + "early" + ], + "🕑": [ + "two_o_clock", + "time", + "late", + "early" + ], + "🕝": [ + "two_thirty", + "time", + "late", + "early" + ], + "🕒": [ + "three_o_clock", + "time", + "late", + "early" + ], + "🕞": [ + "three_thirty", + "time", + "late", + "early" + ], + "🕓": [ + "four_o_clock", + "time", + "late", + "early" + ], + "🕟": [ + "four_thirty", + "time", + "late", + "early" + ], + "🕔": [ + "five_o_clock", + "time", + "late", + "early" + ], + "🕠": [ + "five_thirty", + "time", + "late", + "early" + ], + "🕕": [ + "six_o_clock", + "time", + "late", + "early", + "dawn", + "dusk" + ], + "🕡": [ + "six_thirty", + "time", + "late", + "early" + ], + "🕖": [ + "seven_o_clock", + "time", + "late", + "early" + ], + "🕢": [ + "seven_thirty", + "time", + "late", + "early" + ], + "🕗": [ + "eight_o_clock", + "time", + "late", + "early" + ], + "🕣": [ + "eight_thirty", + "time", + "late", + "early" + ], + "🕘": [ + "nine_o_clock", + "time", + "late", + "early" + ], + "🕤": [ + "nine_thirty", + "time", + "late", + "early" + ], + "🕙": [ + "ten_o_clock", + "time", + "late", + "early" + ], + "🕥": [ + "ten_thirty", + "time", + "late", + "early" + ], + "🕚": [ + "eleven_o_clock", + "time", + "late", + "early" + ], + "🕦": [ + "eleven_thirty", + "time", + "late", + "early" + ], + "🌑": [ + "new_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep", + "move to tmrw" + ], + "🌒": [ + "waxing_crescent_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌓": [ + "first_quarter_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌔": [ + "waxing_gibbous_moon", + "nature", + "night", + "sky", + "gray", + "twilight", + "planet", + "space", + "evening", + "sleep" + ], + "🌕": [ + "full_moon", + "nature", + "yellow", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌖": [ + "waning_gibbous_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep", + "waxing_gibbous_moon" + ], + "🌗": [ + "last_quarter_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌘": [ + "waning_crescent_moon", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌙": [ + "crescent_moon", + "night", + "sleep", + "sky", + "evening", + "magic" + ], + "🌚": [ + "new_moon_face", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌛": [ + "first_quarter_moon_face", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌜": [ + "last_quarter_moon_face", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌡️": [ + "thermometer", + "weather", + "temperature", + "hot", + "cold" + ], + "☀️": [ + "sun", + "weather", + "nature", + "brightness", + "summer", + "beach", + "spring" + ], + "🌝": [ + "full_moon_face", + "nature", + "twilight", + "planet", + "space", + "night", + "evening", + "sleep" + ], + "🌞": [ + "sun_with_face", + "nature", + "morning", + "sky" + ], + "🪐": [ + "ringed_planet", + "outerspace" + ], + "⭐": [ + "star", + "night", + "yellow" + ], + "🌟": [ + "glowing_star", + "night", + "sparkle", + "awesome", + "good", + "magic" + ], + "🌠": [ + "shooting_star", + "night", + "photo" + ], + "🌌": [ + "milky_way", + "photo", + "space", + "stars" + ], + "☁️": [ + "cloud", + "weather", + "sky" + ], + "⛅": [ + "sun_behind_cloud", + "weather", + "nature", + "cloudy", + "morning", + "fall", + "spring" + ], + "⛈️": [ + "cloud_with_lightning_and_rain", + "weather", + "lightning" + ], + "🌤️": [ + "sun_behind_small_cloud", + "weather" + ], + "🌥️": [ + "sun_behind_large_cloud", + "weather" + ], + "🌦️": [ + "sun_behind_rain_cloud", + "weather" + ], + "🌧️": [ + "cloud_with_rain", + "weather" + ], + "🌨️": [ + "cloud_with_snow", + "weather" + ], + "🌩️": [ + "cloud_with_lightning", + "weather", + "thunder" + ], + "🌪️": [ + "tornado", + "weather", + "cyclone", + "twister" + ], + "🌫️": [ + "fog", + "weather" + ], + "🌬️": [ + "wind_face", + "gust", + "air" + ], + "🌀": [ + "cyclone", + "weather", + "swirl", + "blue", + "cloud", + "vortex", + "spiral", + "whirlpool", + "spin", + "tornado", + "hurricane", + "typhoon" + ], + "🌈": [ + "rainbow", + "nature", + "happy", + "unicorn_face", + "photo", + "sky", + "spring" + ], + "🌂": [ + "closed_umbrella", + "weather", + "rain", + "drizzle" + ], + "☂️": [ + "umbrella", + "weather", + "spring" + ], + "☔": [ + "umbrella_with_rain_drops", + "rainy", + "weather", + "spring" + ], + "⛱️": [ + "umbrella_on_ground", + "weather", + "summer" + ], + "⚡": [ + "high_voltage", + "thunder", + "weather", + "lightning bolt", + "fast" + ], + "❄️": [ + "snowflake", + "winter", + "season", + "cold", + "weather", + "christmas", + "xmas" + ], + "☃️": [ + "snowman", + "winter", + "season", + "cold", + "weather", + "christmas", + "xmas", + "frozen" + ], + "⛄": [ + "snowman_without_snow", + "winter", + "season", + "cold", + "weather", + "christmas", + "xmas", + "frozen", + "without_snow" + ], + "☄️": [ + "comet", + "space" + ], + "🔥": [ + "fire", + "hot", + "cook", + "flame" + ], + "💧": [ + "droplet", + "water", + "drip", + "faucet", + "spring" + ], + "🌊": [ + "water_wave", + "sea", + "water", + "wave", + "nature", + "tsunami", + "disaster" + ], + "🎃": [ + "jack_o_lantern", + "halloween", + "light", + "pumpkin", + "creepy", + "fall" + ], + "🎄": [ + "christmas_tree", + "festival", + "vacation", + "december", + "xmas", + "celebration" + ], + "🎆": [ + "fireworks", + "photo", + "festival", + "carnival", + "congratulations" + ], + "🎇": [ + "sparkler", + "stars", + "night", + "shine" + ], + "🧨": [ + "firecracker", + "dynamite", + "boom", + "explode", + "explosion", + "explosive" + ], + "✨": [ + "sparkles", + "stars", + "shine", + "shiny", + "cool", + "awesome", + "good", + "magic" + ], + "🎈": [ + "balloon", + "party", + "celebration", + "birthday", + "circus" + ], + "🎉": [ + "party_popper", + "party", + "congratulations", + "birthday", + "magic", + "circus", + "celebration", + "tada" + ], + "🎊": [ + "confetti_ball", + "festival", + "party", + "birthday", + "circus" + ], + "🎋": [ + "tanabata_tree", + "plant", + "nature", + "branch", + "summer", + "bamboo", + "wish", + "star_festival", + "tanzaku" + ], + "🎍": [ + "pine_decoration", + "japanese", + "plant", + "nature", + "vegetable", + "panda", + "new_years", + "bamboo" + ], + "🎎": [ + "japanese_dolls", + "japanese", + "toy", + "kimono" + ], + "🎏": [ + "carp_streamer", + "fish", + "japanese", + "koinobori", + "carp", + "banner" + ], + "🎐": [ + "wind_chime", + "nature", + "ding", + "spring", + "bell" + ], + "🎑": [ + "moon_viewing_ceremony", + "photo", + "japan", + "asia", + "tsukimi" + ], + "🧧": [ + "red_envelope" + ], + "🎀": [ + "ribbon", + "decoration", + "pink", + "girl", + "bowtie" + ], + "🎁": [ + "wrapped_gift", + "present", + "birthday", + "christmas", + "xmas", + "gift", + "подарок" + ], + "🎗️": [ + "reminder_ribbon", + "sports", + "cause", + "support", + "awareness" + ], + "🎟️": [ + "admission_tickets", + "sports", + "concert", + "entrance" + ], + "🎫": [ + "ticket", + "event", + "concert", + "pass" + ], + "🎖️": [ + "military_medal", + "award", + "winning", + "army" + ], + "🏆": [ + "trophy", + "win", + "award", + "contest", + "place", + "ftw", + "ceremony" + ], + "🏅": [ + "sports_medal", + "award", + "winning" + ], + "🥇": [ + "1st_place_medal", + "award", + "winning" + ], + "🥈": [ + "2nd_place_medal", + "award", + "second" + ], + "🥉": [ + "3rd_place_medal", + "award", + "third" + ], + "⚽": [ + "soccer_ball", + "sports", + "football" + ], + "⚾": [ + "baseball", + "sports", + "balls" + ], + "🥎": [ + "softball", + "sports", + "balls" + ], + "🏀": [ + "basketball", + "sports", + "balls", + "NBA" + ], + "🏐": [ + "volleyball", + "sports", + "balls" + ], + "🏈": [ + "american_football", + "sports", + "balls", + "NFL" + ], + "🏉": [ + "rugby_football", + "sports", + "team" + ], + "🎾": [ + "tennis", + "sports", + "balls", + "green" + ], + "🥏": [ + "flying_disc", + "sports", + "frisbee", + "ultimate" + ], + "🎳": [ + "bowling", + "sports", + "fun", + "play" + ], + "🏏": [ + "cricket_game", + "sports" + ], + "🏑": [ + "field_hockey", + "sports" + ], + "🏒": [ + "ice_hockey", + "sports" + ], + "🥍": [ + "lacrosse", + "sports", + "ball", + "stick" + ], + "🏓": [ + "ping_pong", + "sports", + "pingpong" + ], + "🏸": [ + "badminton", + "sports" + ], + "🥊": [ + "boxing_glove", + "sports", + "fighting" + ], + "🥋": [ + "martial_arts_uniform", + "judo", + "karate", + "taekwondo" + ], + "🥅": [ + "goal_net", + "sports" + ], + "⛳": [ + "flag_in_hole", + "sports", + "business", + "flag", + "hole", + "summer" + ], + "⛸️": [ + "ice_skate", + "sports" + ], + "🎣": [ + "fishing_pole", + "food", + "hobby", + "summer" + ], + "🤿": [ + "diving_mask", + "sport", + "ocean" + ], + "🎽": [ + "running_shirt", + "play", + "pageant" + ], + "🎿": [ + "skis", + "sports", + "winter", + "cold", + "snow" + ], + "🛷": [ + "sled", + "sleigh", + "luge", + "toboggan" + ], + "🥌": [ + "curling_stone", + "sports" + ], + "🎯": [ + "direct_hit", + "game", + "play", + "bar", + "target", + "bullseye" + ], + "🪀": [ + "yo_yo", + "toy" + ], + "🪁": [ + "kite", + "wind", + "fly" + ], + "🎱": [ + "pool_8_ball", + "pool", + "hobby", + "game", + "luck", + "magic" + ], + "🔮": [ + "crystal_ball", + "disco", + "party", + "magic", + "circus", + "fortune_teller" + ], + "🧿": [ + "nazar_amulet", + "bead", + "charm" + ], + "🎮": [ + "video_game", + "play", + "console", + "PS4", + "controller" + ], + "🕹️": [ + "joystick", + "game", + "play" + ], + "🎰": [ + "slot_machine", + "bet", + "gamble", + "vegas", + "fruit machine", + "luck", + "casino" + ], + "🎲": [ + "game_die", + "dice", + "random", + "tabletop", + "play", + "luck" + ], + "🧩": [ + "puzzle_piece", + "interlocking", + "puzzle", + "piece", + "hob" + ], + "🧸": [ + "teddy_bear", + "plush", + "stuffed" + ], + "♠️": [ + "spade_suit", + "poker", + "cards", + "suits", + "magic" + ], + "♥️": [ + "heart_suit", + "poker", + "cards", + "magic", + "suits" + ], + "♦️": [ + "diamond_suit", + "poker", + "cards", + "magic", + "suits" + ], + "♣️": [ + "club_suit", + "poker", + "cards", + "magic", + "suits" + ], + "♟️": [ + "chess_pawn", + "expendable" + ], + "🃏": [ + "joker", + "poker", + "cards", + "game", + "play", + "magic" + ], + "🀄": [ + "mahjong_red_dragon", + "game", + "play", + "chinese", + "kanji" + ], + "🎴": [ + "flower_playing_cards", + "game", + "sunset", + "red" + ], + "🎭": [ + "performing_arts", + "acting", + "theater", + "drama" + ], + "🖼️": [ + "framed_picture", + "photography" + ], + "🎨": [ + "artist_palette", + "design", + "paint", + "draw", + "colors" + ], + "🧵": [ + "thread", + "needle", + "sewing", + "spool", + "string" + ], + "🧶": [ + "yarn", + "ball", + "crochet", + "knit" + ], + "👓": [ + "glasses", + "fashion", + "accessories", + "eyesight", + "nerdy", + "dork", + "geek" + ], + "🕶️": [ + "sunglasses", + "face", + "cool", + "accessories" + ], + "🥽": [ + "goggles", + "protection", + "safety" + ], + "🥼": [ + "lab_coat", + "doctor", + "experiment", + "scientist", + "chemist" + ], + "🦺": [ + "safety_vest", + "protection" + ], + "👔": [ + "necktie", + "shirt", + "suitup", + "formal", + "fashion", + "cloth", + "business", + "interview" + ], + "👕": [ + "t_shirt", + "fashion", + "cloth", + "casual", + "shirt", + "tee" + ], + "👖": [ + "jeans", + "fashion", + "shopping" + ], + "🧣": [ + "scarf", + "neck", + "winter", + "clothes" + ], + "🧤": [ + "gloves", + "hands", + "winter", + "clothes", + "leave" + ], + "🧥": [ + "coat", + "jacket" + ], + "🧦": [ + "socks", + "stockings", + "clothes" + ], + "👗": [ + "dress", + "clothes", + "fashion", + "shopping" + ], + "👘": [ + "kimono", + "dress", + "fashion", + "women", + "female", + "japanese" + ], + "🥻": [ + "sari", + "dress" + ], + "🩱": [ + "one_piece_swimsuit", + "fashion" + ], + "🩲": [ + "briefs", + "clothing" + ], + "🩳": [ + "shorts", + "clothing" + ], + "👙": [ + "bikini", + "swimming", + "female", + "woman", + "girl", + "fashion", + "beach", + "summer" + ], + "👚": [ + "woman_s_clothes", + "fashion", + "shopping_bags", + "female" + ], + "👛": [ + "purse", + "fashion", + "accessories", + "money", + "sales", + "shopping" + ], + "👜": [ + "handbag", + "fashion", + "accessory", + "accessories", + "shopping" + ], + "👝": [ + "clutch_bag", + "bag", + "accessories", + "shopping" + ], + "🛍️": [ + "shopping_bags", + "mall", + "buy", + "purchase", + "купить" + ], + "🎒": [ + "backpack", + "student", + "education", + "bag", + "backpack" + ], + "👞": [ + "man_s_shoe", + "fashion", + "male" + ], + "👟": [ + "running_shoe", + "shoes", + "sports", + "sneakers" + ], + "🥾": [ + "hiking_boot", + "backpacking", + "camping", + "hiking" + ], + "🥿": [ + "flat_shoe", + "ballet", + "slip-on", + "slipper" + ], + "👠": [ + "high_heeled_shoe", + "fashion", + "shoes", + "female", + "pumps", + "stiletto" + ], + "👡": [ + "woman_s_sandal", + "shoes", + "fashion", + "flip flops" + ], + "🩰": [ + "ballet_shoes", + "dance" + ], + "👢": [ + "woman_s_boot", + "shoes", + "fashion" + ], + "👑": [ + "crown", + "king", + "kod", + "leader", + "royalty", + "lord" + ], + "👒": [ + "woman_s_hat", + "fashion", + "accessories", + "female", + "lady", + "spring" + ], + "🎩": [ + "top_hat", + "magic", + "gentleman", + "classy", + "circus" + ], + "🎓": [ + "graduation_cap", + "school", + "college", + "degree", + "university", + "graduation", + "cap", + "hat", + "legal", + "learn", + "education" + ], + "🧢": [ + "billed_cap", + "cap", + "baseball" + ], + "⛑️": [ + "rescue_worker_s_helmet", + "construction", + "build" + ], + "📿": [ + "prayer_beads", + "dhikr", + "religious" + ], + "💄": [ + "lipstick", + "female", + "girl", + "fashion", + "woman" + ], + "💍": [ + "ring", + "wedding", + "propose", + "marriage", + "valentines", + "diamond", + "fashion", + "jewelry", + "gem", + "engagement" + ], + "💎": [ + "gem_stone", + "blue", + "ruby", + "diamond", + "jewelry" + ], + "🔇": [ + "muted_speaker", + "sound", + "volume", + "silence", + "quiet" + ], + "🔈": [ + "speaker_low_volume", + "sound", + "volume", + "silence", + "broadcast" + ], + "🔉": [ + "speaker_medium_volume", + "volume", + "speaker", + "broadcast" + ], + "🔊": [ + "speaker_high_volume", + "volume", + "noise", + "noisy", + "speaker", + "broadcast" + ], + "📢": [ + "loudspeaker", + "volume", + "sound" + ], + "📣": [ + "megaphone", + "sound", + "speaker", + "volume" + ], + "📯": [ + "postal_horn", + "instrument", + "music" + ], + "🔔": [ + "bell", + "sound", + "notification", + "christmas", + "xmas", + "chime" + ], + "🔕": [ + "bell_with_slash", + "sound", + "volume", + "mute", + "quiet", + "silent" + ], + "🎼": [ + "musical_score", + "treble", + "clef", + "compose" + ], + "🎵": [ + "musical_note", + "score", + "tone", + "sound" + ], + "🎶": [ + "musical_notes", + "music", + "score" + ], + "🎙️": [ + "studio_microphone", + "sing", + "recording", + "artist", + "talkshow" + ], + "🎚️": [ + "level_slider", + "scale" + ], + "🎛️": [ + "control_knobs" + ], + "🎤": [ + "microphone", + "sound", + "music", + "PA", + "sing", + "talkshow" + ], + "🎧": [ + "headphone", + "music", + "score", + "gadgets" + ], + "📻": [ + "radio", + "communication", + "music", + "podcast", + "program" + ], + "🎷": [ + "saxophone", + "music", + "instrument", + "jazz", + "blues" + ], + "🎸": [ + "guitar", + "music", + "instrument" + ], + "🎹": [ + "musical_keyboard", + "piano", + "instrument", + "compose" + ], + "🎺": [ + "trumpet", + "music", + "brass" + ], + "🎻": [ + "violin", + "music", + "instrument", + "orchestra", + "symphony" + ], + "🪕": [ + "banjo", + "music", + "instructment" + ], + "🥁": [ + "drum", + "music", + "instrument", + "drumsticks", + "snare" + ], + "📱": [ + "mobile_phone", + "technology", + "gadgets", + "dial" + ], + "📲": [ + "mobile_phone_with_arrow", + "iphone", + "incoming" + ], + "☎️": [ + "telephone", + "technology", + "communication", + "dial", + "telephone", + "call", + "позвонить" + ], + "📞": [ + "telephone_receiver", + "technology", + "communication", + "dial" + ], + "📟": [ + "pager", + "bbcall", + "oldschool", + "90s" + ], + "📠": [ + "fax_machine", + "communication", + "technology" + ], + "🔋": [ + "battery", + "power", + "energy", + "sustain" + ], + "🔌": [ + "electric_plug", + "charger", + "power" + ], + "💻": [ + "laptop", + "technology", + "laptop", + "screen", + "display", + "monitor" + ], + "🖥️": [ + "desktop_computer", + "technology", + "computing", + "screen" + ], + "🖨️": [ + "printer", + "paper", + "ink" + ], + "⌨️": [ + "keyboard", + "technology", + "computer", + "type", + "input", + "text" + ], + "🖱️": [ + "computer_mouse", + "click" + ], + "🖲️": [ + "trackball", + "technology", + "trackpad" + ], + "💽": [ + "computer_disk", + "technology", + "record", + "data", + "disk", + "90s" + ], + "💾": [ + "floppy_disk", + "oldschool", + "technology", + "save", + "90s", + "80s", + "unsorted", + "saved" + ], + "💿": [ + "optical_disk", + "technology", + "dvd", + "disk", + "disc", + "90s" + ], + "📀": [ + "dvd", + "cd", + "disk", + "disc" + ], + "🧮": [ + "abacus", + "calculation" + ], + "🎥": [ + "movie_camera", + "film", + "record" + ], + "🎞️": [ + "film_frames", + "movie" + ], + "📽️": [ + "film_projector", + "video", + "tape", + "record", + "movie" + ], + "🎬": [ + "clapper_board", + "movie", + "film", + "record" + ], + "📷": [ + "camera", + "gadgets", + "photography" + ], + "📸": [ + "camera_with_flash", + "photography", + "gadgets" + ], + "📹": [ + "video_camera", + "film", + "record" + ], + "📼": [ + "videocassette", + "record", + "video", + "oldschool", + "90s", + "80s" + ], + "🔍": [ + "magnifying_glass_tilted_left", + "SEO", + "zoom", + "find", + "detective" + ], + "🔎": [ + "magnifying_glass_tilted_right", + "search", + "zoom", + "find", + "detective" + ], + "🕯️": [ + "candle", + "fire", + "wax" + ], + "💡": [ + "light_bulb", + "light", + "electricity", + "idea", + "идея", + "идеи", + "thought" + ], + "🔦": [ + "flashlight", + "dark", + "camping", + "sight", + "night" + ], + "🏮": [ + "red_paper_lantern", + "light", + "paper", + "halloween", + "spooky" + ], + "🪔": [ + "diya_lamp", + "lighting" + ], + "📔": [ + "notebook_with_decorative_cover", + "classroom", + "record", + "paper", + "study" + ], + "📕": [ + "closed_book", + "library", + "knowledge", + "textbook", + "learn" + ], + "📖": [ + "open_book", + "book", + "прочитать", + "library", + "knowledge", + "literature", + "learn", + "study" + ], + "📗": [ + "green_book", + "library", + "knowledge", + "study" + ], + "📘": [ + "blue_book", + "library", + "knowledge", + "learn", + "study" + ], + "📙": [ + "orange_book", + "library", + "knowledge", + "textbook", + "study" + ], + "📚": [ + "books", + "literature", + "library", + "study", + "read", + "reading list", + "прочитать", + "to read" + ], + "📓": [ + "notebook", + "stationery", + "record", + "paper", + "study" + ], + "📒": [ + "ledger", + "paper" + ], + "📃": [ + "page_with_curl", + "documents", + "office", + "paper" + ], + "📜": [ + "scroll", + "documents", + "ancient", + "history", + "paper" + ], + "📄": [ + "page_facing_up", + "documents", + "office", + "paper", + "information", + "file", + "files", + "to file" + ], + "📰": [ + "newspaper", + "press", + "headline" + ], + "🗞️": [ + "rolled_up_newspaper", + "press", + "headline" + ], + "📑": [ + "bookmark_tabs", + "favorite", + "save", + "order", + "tidy" + ], + "🔖": [ + "bookmark", + "favorite", + "label", + "save" + ], + "🏷️": [ + "label", + "sale", + "tag" + ], + "💰": [ + "money_bag", + "dollar", + "payment", + "coins", + "sale", + "pay", + "заплатить" + ], + "💴": [ + "yen_banknote", + "money", + "sales", + "japanese", + "dollar", + "currency" + ], + "💵": [ + "dollar_banknote", + "money", + "sales", + "bill", + "currency" + ], + "💶": [ + "euro_banknote", + "money", + "sales", + "dollar", + "currency" + ], + "💷": [ + "pound_banknote", + "british", + "sterling", + "money", + "sales", + "bills", + "uk", + "england", + "currency" + ], + "💸": [ + "money_with_wings", + "dollar", + "bills", + "payment", + "sale" + ], + "💳": [ + "credit_card", + "money", + "sales", + "dollar", + "bill", + "payment", + "shopping" + ], + "🧾": [ + "receipt", + "accounting", + "expenses" + ], + "💹": [ + "chart_increasing_with_yen", + "green-square", + "graph", + "presentation", + "stats" + ], + "💱": [ + "currency_exchange", + "money", + "sales", + "dollar" + ], + "💲": [ + "heavy_dollar_sign", + "money", + "sales", + "payment", + "currency", + "buck" + ], + "✉️": [ + "envelope", + "letter", + "postal", + "communication", + "send", + "выслать", + "отправить" + ], + "📧": [ + "e_mail", + "communication" + ], + "📨": [ + "incoming_envelope", + "email" + ], + "📩": [ + "envelope_with_arrow", + "email", + "communication" + ], + "📤": [ + "outbox_tray", + "inbox", + "email" + ], + "📥": [ + "inbox_tray", + "email", + "documents" + ], + "📦": [ + "package", + "mail", + "cardboard", + "box", + "moving" + ], + "📫": [ + "closed_mailbox_with_raised_flag", + "email", + "inbox", + "communication" + ], + "📪": [ + "closed_mailbox_with_lowered_flag", + "email", + "communication", + "inbox" + ], + "📬": [ + "open_mailbox_with_raised_flag", + "email", + "inbox", + "communication" + ], + "📭": [ + "open_mailbox_with_lowered_flag", + "email", + "inbox" + ], + "📮": [ + "postbox", + "email", + "letter", + "envelope" + ], + "🗳️": [ + "ballot_box_with_ballot", + "election", + "vote" + ], + "✏️": [ + "pencil", + "stationery", + "write", + "paper", + "writing", + "school", + "study", + "написать", + "изучить" + ], + "✒️": [ + "black_nib", + "pen", + "stationery", + "writing", + "write", + "fill", + "заполнить" + ], + "🖋️": [ + "fountain_pen", + "stationery", + "writing", + "write" + ], + "🖊️": [ + "pen", + "stationery", + "writing", + "write" + ], + "🖌️": [ + "paintbrush", + "drawing", + "creativity", + "art" + ], + "🖍️": [ + "crayon", + "drawing", + "creativity" + ], + "📝": [ + "memo", + "documents", + "stationery", + "pencil", + "paper", + "writing", + "legal", + "exam", + "quiz", + "study", + "compose" + ], + "💼": [ + "briefcase", + "business", + "documents", + "work", + "law", + "legal", + "job", + "career", + "wrk" + ], + "📁": [ + "file_folder", + "documents", + "business", + "office" + ], + "📂": [ + "open_file_folder", + "documents", + "load", + "dirs" + ], + "🗂️": [ + "card_index_dividers", + "organizing", + "business", + "stationery", + "dir" + ], + "📆": [ + "tear_off_calendar", + "date", + "planning", + "move to a day" + ], + "🗒️": [ + "spiral_notepad", + "memo", + "stationery" + ], + "🗓": [ + "spiral_calendar", + "date", + "schedule", + "planning" + ], + "📇": [ + "card_index", + "business", + "stationery" + ], + "📈": [ + "chart_increasing", + "graph", + "presentation", + "stats", + "recovery", + "business", + "economics", + "money", + "sales", + "good", + "success" + ], + "📉": [ + "chart_decreasing", + "graph", + "presentation", + "stats", + "recession", + "business", + "economics", + "money", + "sales", + "bad", + "failure" + ], + "📊": [ + "bar_chart", + "graph", + "presentation", + "stats" + ], + "📋": [ + "clipboard", + "stationery", + "documents" + ], + "📌": [ + "pushpin", + "stationery", + "mark", + "here", + "notes", + "note" + ], + "📍": [ + "round_pushpin", + "stationery", + "location", + "map", + "here" + ], + "📎": [ + "paperclip", + "documents", + "stationery" + ], + "🖇️": [ + "linked_paperclips", + "documents", + "stationery" + ], + "📏": [ + "straight_ruler", + "stationery", + "calculate", + "length", + "math", + "school", + "drawing", + "architect", + "sketch" + ], + "📐": [ + "triangular_ruler", + "stationery", + "math", + "architect", + "sketch" + ], + "✂️": [ + "scissors", + "stationery", + "cut" + ], + "🗃️": [ + "card_file_box", + "business", + "stationery" + ], + "🗄️": [ + "file_cabinet", + "filing", + "organizing" + ], + "🗑️": [ + "wastebasket", + "bin", + "trash", + "rubbish", + "garbage", + "toss", + "archive" + ], + "🔒": [ + "locked", + "security", + "password", + "padlock" + ], + "🔓": [ + "unlocked", + "privacy", + "security" + ], + "🔏": [ + "locked_with_pen", + "security", + "secret" + ], + "🔐": [ + "locked_with_key", + "security", + "privacy" + ], + "🔑": [ + "key", + "lock", + "door", + "password", + "ключ", + "ключи" + ], + "🗝️": [ + "old_key", + "lock", + "door", + "password" + ], + "🔨": [ + "hammer", + "tools", + "build", + "create" + ], + "🪓": [ + "axe", + "tool", + "chop", + "cut" + ], + "⛏️": [ + "pick", + "tools", + "dig" + ], + "⚒️": [ + "hammer_and_pick", + "tools", + "build", + "create" + ], + "🛠️": [ + "hammer_and_wrench", + "tools", + "build", + "create" + ], + "🗡️": [ + "dagger", + "weapon" + ], + "⚔️": [ + "crossed_swords", + "weapon" + ], + "🔫": [ + "pistol", + "violence", + "weapon", + "pistol", + "revolver" + ], + "🏹": [ + "bow_and_arrow", + "sports" + ], + "🛡️": [ + "shield", + "protection", + "security" + ], + "🔧": [ + "wrench", + "tools", + "diy", + "ikea", + "fix", + "maintainer" + ], + "🔩": [ + "nut_and_bolt", + "handy", + "tools", + "fix" + ], + "⚙️": [ + "gear", + "cog", + "set", + "set up", + "настроить" + ], + "🗜️": [ + "clamp", + "tool" + ], + "⚖️": [ + "balance_scale", + "law", + "fairness", + "weight" + ], + "🦯": [ + "probing_cane", + "accessibility" + ], + "🔗": [ + "link", + "rings", + "url" + ], + "⛓️": [ + "chains", + "lock", + "arrest" + ], + "🧰": [ + "toolbox", + "tools", + "diy", + "fix", + "maintainer", + "mechanic" + ], + "🧲": [ + "magnet", + "attraction", + "magnetic" + ], + "⚗️": [ + "alembic", + "distilling", + "science", + "experiment", + "chemistry" + ], + "🧪": [ + "test_tube", + "chemistry", + "experiment", + "lab", + "science", + "test" + ], + "🧫": [ + "petri_dish", + "bacteria", + "biology", + "culture", + "lab" + ], + "🧬": [ + "dna", + "biologist", + "genetics", + "life" + ], + "🔬": [ + "microscope", + "laboratory", + "experiment", + "zoomin", + "science", + "study" + ], + "🔭": [ + "telescope", + "stars", + "space", + "zoom", + "science", + "astronomy" + ], + "📡": [ + "satellite_antenna", + "communication", + "future", + "radio", + "space" + ], + "💉": [ + "syringe", + "health", + "hospital", + "drugs", + "blood", + "medicine", + "needle", + "doctor", + "nurse" + ], + "🩸": [ + "drop_of_blood", + "period", + "hurt", + "harm", + "wound" + ], + "💊": [ + "pill", + "health", + "medicine", + "doctor", + "pharmacy", + "drug", + "vitamin", + "vitamins", + "витамины" + ], + "🩹": [ + "adhesive_bandage", + "heal" + ], + "🩺": [ + "stethoscope", + "health" + ], + "🚪": [ + "door", + "house", + "entry", + "exit" + ], + "🛏️": [ + "bed", + "sleep", + "rest" + ], + "🛋️": [ + "couch_and_lamp", + "chill" + ], + "🪑": [ + "chair", + "sit", + "furniture" + ], + "🚽": [ + "toilet", + "restroom", + "wc", + "washroom", + "bathroom", + "potty" + ], + "🚿": [ + "shower", + "clean", + "water", + "bathroom" + ], + "🛁": [ + "bathtub", + "clean", + "shower", + "bathroom" + ], + "🪒": [ + "razor", + "cut" + ], + "🧴": [ + "lotion_bottle", + "moisturizer", + "sunscreen" + ], + "🧷": [ + "safety_pin", + "diaper" + ], + "🧹": [ + "broom", + "cleaning", + "sweeping", + "witch" + ], + "🧺": [ + "basket", + "laundry" + ], + "🧻": [ + "roll_of_paper", + "roll" + ], + "🧼": [ + "soap", + "bar", + "bathing", + "cleaning", + "lather" + ], + "🧽": [ + "sponge", + "absorbing", + "cleaning", + "porous" + ], + "🧯": [ + "fire_extinguisher", + "quench" + ], + "🛒": [ + "shopping_cart", + "trolley", + "shop", + "shopping list", + "to shop" + ], + "🚬": [ + "cigarette", + "kills", + "tobacco", + "cigarette", + "joint", + "smoke" + ], + "⚰️": [ + "coffin", + "vampire", + "dead", + "die", + "death", + "rip", + "graveyard", + "cemetery", + "casket", + "funeral", + "box" + ], + "⚱️": [ + "funeral_urn", + "dead", + "die", + "death", + "rip", + "ashes" + ], + "🗿": [ + "moai", + "rock", + "easter island", + "moai" + ], + "🏧": [ + "atm_sign", + "money", + "sales", + "cash", + "blue-square", + "payment", + "bank" + ], + "🚮": [ + "litter_in_bin_sign", + "blue-square", + "sign", + "human", + "info" + ], + "🚰": [ + "potable_water", + "blue-square", + "liquid", + "restroom", + "cleaning", + "faucet" + ], + "♿": [ + "wheelchair_symbol", + "blue-square", + "disabled", + "accessibility" + ], + "🚹": [ + "men_s_room", + "toilet", + "restroom", + "wc", + "blue-square", + "gender", + "male" + ], + "🚺": [ + "women_s_room", + "purple-square", + "woman", + "female", + "toilet", + "loo", + "restroom", + "gender" + ], + "🚻": [ + "restroom", + "blue-square", + "toilet", + "refresh", + "wc", + "gender" + ], + "🚼": [ + "baby_symbol", + "orange-square", + "child" + ], + "🚾": [ + "water_closet", + "toilet", + "restroom", + "blue-square" + ], + "🛂": [ + "passport_control", + "custom", + "blue-square" + ], + "🛃": [ + "customs", + "passport", + "border", + "blue-square" + ], + "🛄": [ + "baggage_claim", + "blue-square", + "airport", + "transport" + ], + "🛅": [ + "left_luggage", + "blue-square", + "travel" + ], + "⚠️": [ + "warning", + "exclamation", + "wip", + "alert", + "error", + "problem", + "issue" + ], + "🚸": [ + "children_crossing", + "school", + "warning", + "danger", + "sign", + "driving", + "yellow-diamond" + ], + "⛔": [ + "no_entry", + "limit", + "security", + "privacy", + "bad", + "denied", + "stop", + "circle" + ], + "🚫": [ + "prohibited", + "forbid", + "stop", + "limit", + "denied", + "disallow", + "circle" + ], + "🚳": [ + "no_bicycles", + "cyclist", + "prohibited", + "circle" + ], + "🚭": [ + "no_smoking", + "cigarette", + "blue-square", + "smell", + "smoke" + ], + "🚯": [ + "no_littering", + "trash", + "bin", + "garbage", + "circle" + ], + "🚱": [ + "non_potable_water", + "drink", + "faucet", + "tap", + "circle" + ], + "🚷": [ + "no_pedestrians", + "rules", + "crossing", + "walking", + "circle" + ], + "📵": [ + "no_mobile_phones", + "iphone", + "mute", + "circle" + ], + "🔞": [ + "no_one_under_eighteen", + "18", + "drink", + "pub", + "night", + "minor", + "circle" + ], + "☢️": [ + "radioactive", + "nuclear", + "danger" + ], + "☣️": [ + "biohazard", + "danger" + ], + "⬆️": [ + "up_arrow", + "blue-square", + "continue", + "top", + "direction" + ], + "↗️": [ + "up_right_arrow", + "blue-square", + "point", + "direction", + "diagonal", + "northeast" + ], + "➡️": [ + "right_arrow", + "blue-square", + "next", + "move" + ], + "↘️": [ + "down_right_arrow", + "blue-square", + "direction", + "diagonal", + "southeast" + ], + "⬇️": [ + "down_arrow", + "blue-square", + "direction", + "bottom", + "download", + "скачать" + ], + "↙️": [ + "down_left_arrow", + "blue-square", + "direction", + "diagonal", + "southwest" + ], + "⬅️": [ + "left_arrow", + "blue-square", + "previous", + "back" + ], + "↖️": [ + "up_left_arrow", + "blue-square", + "point", + "direction", + "diagonal", + "northwest" + ], + "↕️": [ + "up_down_arrow", + "blue-square", + "direction", + "way", + "vertical" + ], + "↔️": [ + "left_right_arrow", + "shape", + "direction", + "horizontal", + "sideways" + ], + "↩️": [ + "right_arrow_curving_left", + "back", + "return", + "blue-square", + "undo", + "enter" + ], + "↪️": [ + "left_arrow_curving_right", + "blue-square", + "return", + "rotate", + "direction" + ], + "⤴️": [ + "right_arrow_curving_up", + "blue-square", + "direction", + "top" + ], + "⤵️": [ + "right_arrow_curving_down", + "blue-square", + "direction", + "bottom" + ], + "🔃": [ + "clockwise_vertical_arrows", + "sync", + "cycle", + "round", + "repeat" + ], + "🔄": [ + "counterclockwise_arrows_button", + "blue-square", + "sync", + "cycle" + ], + "🔙": [ + "back_arrow", + "arrow", + "words", + "return" + ], + "🔚": [ + "end_arrow", + "words", + "arrow" + ], + "🔛": [ + "on_arrow", + "arrow", + "words" + ], + "🔜": [ + "soon_arrow", + "arrow", + "words" + ], + "🔝": [ + "top_arrow", + "words", + "blue-square" + ], + "🛐": [ + "place_of_worship", + "religion", + "church", + "temple", + "prayer" + ], + "⚛️": [ + "atom_symbol", + "science", + "physics", + "chemistry" + ], + "🕉️": [ + "om", + "hinduism", + "buddhism", + "sikhism", + "jainism" + ], + "✡️": [ + "star_of_david", + "judaism" + ], + "☸️": [ + "wheel_of_dharma", + "hinduism", + "buddhism", + "sikhism", + "jainism" + ], + "☯️": [ + "yin_yang", + "balance" + ], + "✝️": [ + "latin_cross", + "christianity" + ], + "☦️": [ + "orthodox_cross", + "suppedaneum", + "religion" + ], + "☪️": [ + "star_and_crescent", + "islam" + ], + "☮️": [ + "peace_symbol", + "hippie" + ], + "🕎": [ + "menorah", + "hanukkah", + "candles", + "jewish" + ], + "🔯": [ + "dotted_six_pointed_star", + "purple-square", + "religion", + "jewish", + "hexagram" + ], + "♈": [ + "aries", + "sign", + "purple-square", + "zodiac", + "astrology" + ], + "♉": [ + "taurus", + "purple-square", + "sign", + "zodiac", + "astrology" + ], + "♊": [ + "gemini", + "sign", + "zodiac", + "purple-square", + "astrology" + ], + "♋": [ + "cancer", + "sign", + "zodiac", + "purple-square", + "astrology" + ], + "♌": [ + "leo", + "sign", + "purple-square", + "zodiac", + "astrology" + ], + "♍": [ + "virgo", + "sign", + "zodiac", + "purple-square", + "astrology" + ], + "♎": [ + "libra", + "sign", + "purple-square", + "zodiac", + "astrology" + ], + "♏": [ + "scorpio", + "sign", + "zodiac", + "purple-square", + "astrology", + "scorpio" + ], + "♐": [ + "sagittarius", + "sign", + "zodiac", + "purple-square", + "astrology" + ], + "♑": [ + "capricorn", + "sign", + "zodiac", + "purple-square", + "astrology" + ], + "♒": [ + "aquarius", + "sign", + "purple-square", + "zodiac", + "astrology" + ], + "♓": [ + "pisces", + "purple-square", + "sign", + "zodiac", + "astrology" + ], + "⛎": [ + "ophiuchus", + "sign", + "purple-square", + "constellation", + "astrology" + ], + "🔀": [ + "shuffle_tracks_button", + "blue-square", + "shuffle", + "music" + ], + "🔁": [ + "repeat_button", + "loop", + "record" + ], + "🔂": [ + "repeat_single_button", + "blue-square", + "loop" + ], + "▶️": [ + "play_button", + "blue-square", + "right", + "direction", + "play" + ], + "⏩": [ + "fast_forward_button", + "blue-square", + "play", + "speed", + "continue" + ], + "⏭️": [ + "next_track_button", + "forward", + "next", + "blue-square" + ], + "⏯️": [ + "play_or_pause_button", + "blue-square", + "play", + "pause" + ], + "◀️": [ + "reverse_button", + "blue-square", + "left", + "direction" + ], + "⏪": [ + "fast_reverse_button", + "play", + "blue-square" + ], + "⏮️": [ + "last_track_button", + "backward" + ], + "🔼": [ + "upwards_button", + "blue-square", + "triangle", + "direction", + "point", + "forward", + "top" + ], + "⏫": [ + "fast_up_button", + "blue-square", + "direction", + "top" + ], + "🔽": [ + "downwards_button", + "blue-square", + "direction", + "bottom" + ], + "⏬": [ + "fast_down_button", + "blue-square", + "direction", + "bottom" + ], + "⏸️": [ + "pause_button", + "pause", + "blue-square" + ], + "⏹️": [ + "stop_button", + "blue-square" + ], + "⏺️": [ + "record_button", + "blue-square" + ], + "⏏️": [ + "eject_button", + "blue-square" + ], + "🎦": [ + "cinema", + "blue-square", + "record", + "film", + "movie", + "curtain", + "stage", + "theater" + ], + "🔅": [ + "dim_button", + "sun", + "afternoon", + "warm", + "summer" + ], + "🔆": [ + "bright_button", + "sun", + "light" + ], + "📶": [ + "antenna_bars", + "blue-square", + "reception", + "phone", + "internet", + "connection", + "wifi", + "bluetooth", + "bars" + ], + "📳": [ + "vibration_mode", + "orange-square", + "phone" + ], + "📴": [ + "mobile_phone_off", + "mute", + "orange-square", + "silence", + "quiet" + ], + "♀️": [ + "female_sign", + "woman", + "women", + "lady", + "girl" + ], + "♂️": [ + "male_sign", + "man", + "boy", + "men" + ], + "⚕️": [ + "medical_symbol", + "health", + "hospital" + ], + "♾️": [ + "infinity", + "forever" + ], + "♻️": [ + "recycling_symbol", + "arrow", + "environment", + "garbage", + "trash" + ], + "⚜️": [ + "fleur_de_lis", + "decorative", + "scout" + ], + "🔱": [ + "trident_emblem", + "weapon", + "spear" + ], + "📛": [ + "name_badge", + "fire", + "forbid" + ], + "🔰": [ + "japanese_symbol_for_beginner", + "badge", + "shield" + ], + "⭕": [ + "hollow_red_circle", + "circle", + "round" + ], + "✅": [ + "check_mark_button", + "green-square", + "ok", + "agree", + "vote", + "election", + "tick", + "tasks" + ], + "☑️": [ + "check_box_with_check", + "check", + "проверить", + "ok", + "agree", + "confirm", + "black-square", + "vote", + "election", + "yes", + "tick", + "checklist", + "checklists" + ], + "✔️": [ + "check_mark", + "ok", + "nike", + "yes", + "tick" + ], + "✖️": [ + "multiplication_sign", + "math", + "calculation" + ], + "❌": [ + "cross_mark", + "no", + "delete", + "remove", + "cancel", + "red" + ], + "❎": [ + "cross_mark_button", + "x", + "green-square", + "no", + "deny" + ], + "➕": [ + "plus_sign", + "math", + "calculation", + "addition", + "more", + "increase", + "add" + ], + "➖": [ + "minus_sign", + "math", + "calculation", + "subtract", + "less" + ], + "➗": [ + "division_sign", + "divide", + "math", + "calculation" + ], + "➰": [ + "curly_loop", + "scribble", + "draw", + "shape", + "squiggle" + ], + "➿": [ + "double_curly_loop", + "tape", + "cassette" + ], + "〽️": [ + "part_alternation_mark", + "graph", + "presentation", + "stats", + "business", + "economics", + "bad" + ], + "✳️": [ + "eight_spoked_asterisk", + "star", + "sparkle", + "green-square" + ], + "✴️": [ + "eight_pointed_star", + "orange-square", + "shape", + "polygon" + ], + "❇️": [ + "sparkle", + "stars", + "green-square", + "awesome", + "good", + "fireworks" + ], + "‼️": [ + "double_exclamation_mark", + "exclamation", + "surprise" + ], + "⁉️": [ + "exclamation_question_mark", + "wat", + "punctuation", + "surprise" + ], + "❓": [ + "question_mark", + "doubt", + "confused" + ], + "❔": [ + "white_question_mark", + "doubts", + "gray", + "huh", + "confused" + ], + "❕": [ + "white_exclamation_mark", + "surprise", + "punctuation", + "gray", + "wow", + "warning" + ], + "❗": [ + "exclamation_mark", + "heavy_exclamation_mark", + "danger", + "surprise", + "punctuation", + "wow", + "warning" + ], + "〰️": [ + "wavy_dash", + "draw", + "line", + "moustache", + "mustache", + "squiggle", + "scribble" + ], + "©️": [ + "copyright", + "ip", + "license", + "circle", + "law", + "legal" + ], + "®️": [ + "registered", + "alphabet", + "circle" + ], + "™️": [ + "trade_mark", + "trademark", + "brand", + "law", + "legal" + ], + "#️⃣": [ + "keycap_", + "symbol", + "blue-square", + "twitter" + ], + "*️⃣": [ + "keycap_", + "star", + "keycap" + ], + "0️⃣": [ + "keycap_0", + "0", + "numbers", + "blue-square", + "null" + ], + "1️⃣": [ + "keycap_1", + "blue-square", + "numbers", + "1" + ], + "2️⃣": [ + "keycap_2", + "numbers", + "2", + "prime", + "blue-square" + ], + "3️⃣": [ + "keycap_3", + "3", + "numbers", + "prime", + "blue-square" + ], + "4️⃣": [ + "keycap_4", + "4", + "numbers", + "blue-square" + ], + "5️⃣": [ + "keycap_5", + "5", + "numbers", + "blue-square", + "prime" + ], + "6️⃣": [ + "keycap_6", + "6", + "numbers", + "blue-square" + ], + "7️⃣": [ + "keycap_7", + "7", + "numbers", + "blue-square", + "prime" + ], + "8️⃣": [ + "keycap_8", + "8", + "blue-square", + "numbers" + ], + "9️⃣": [ + "keycap_9", + "blue-square", + "numbers", + "9" + ], + "🔟": [ + "keycap_10", + "numbers", + "10", + "blue-square" + ], + "🔠": [ + "input_latin_uppercase", + "alphabet", + "words", + "blue-square" + ], + "🔡": [ + "input_latin_lowercase", + "blue-square", + "alphabet" + ], + "🔢": [ + "input_numbers", + "numbers", + "blue-square" + ], + "🔣": [ + "input_symbols", + "blue-square", + "music", + "ampersand", + "percent", + "glyphs", + "characters" + ], + "🔤": [ + "input_latin_letters", + "blue-square", + "alphabet" + ], + "🅰️": [ + "a_button", + "red-square", + "alphabet", + "letter" + ], + "🆎": [ + "ab_button", + "red-square", + "alphabet" + ], + "🅱️": [ + "b_button", + "red-square", + "alphabet", + "letter" + ], + "🆑": [ + "cl_button", + "alphabet", + "words", + "red-square" + ], + "🆒": [ + "cool_button", + "words", + "blue-square" + ], + "🆓": [ + "free_button", + "blue-square", + "words" + ], + "ℹ️": [ + "information", + "blue-square", + "alphabet", + "letter" + ], + "🆔": [ + "id_button", + "purple-square", + "words" + ], + "Ⓜ️": [ + "circled_m", + "alphabet", + "blue-circle", + "letter" + ], + "🆕": [ + "new_button", + "blue-square", + "words", + "start" + ], + "🆖": [ + "ng_button", + "blue-square", + "words", + "shape", + "icon" + ], + "🅾️": [ + "o_button", + "alphabet", + "red-square", + "letter" + ], + "🆗": [ + "ok_button", + "good", + "agree", + "yes", + "blue-square" + ], + "🅿️": [ + "p_button", + "cars", + "blue-square", + "alphabet", + "letter" + ], + "🆘": [ + "sos_button", + "help", + "red-square", + "words", + "emergency", + "911" + ], + "🆙": [ + "up_button", + "blue-square", + "above", + "high" + ], + "🆚": [ + "vs_button", + "words", + "orange-square" + ], + "🈁": [ + "japanese_here_button", + "blue-square", + "here", + "katakana", + "japanese", + "destination" + ], + "🈂️": [ + "japanese_service_charge_button", + "japanese", + "blue-square", + "katakana" + ], + "🈷️": [ + "japanese_monthly_amount_button", + "chinese", + "month", + "moon", + "japanese", + "orange-square", + "kanji" + ], + "🈶": [ + "japanese_not_free_of_charge_button", + "orange-square", + "chinese", + "have", + "kanji" + ], + "🈯": [ + "japanese_reserved_button", + "chinese", + "point", + "green-square", + "kanji" + ], + "🉐": [ + "japanese_bargain_button", + "chinese", + "kanji", + "obtain", + "get", + "circle" + ], + "🈹": [ + "japanese_discount_button", + "cut", + "divide", + "chinese", + "kanji", + "pink-square" + ], + "🈚": [ + "japanese_free_of_charge_button", + "nothing", + "chinese", + "kanji", + "japanese", + "orange-square" + ], + "🈲": [ + "japanese_prohibited_button", + "kanji", + "japanese", + "chinese", + "forbidden", + "limit", + "restricted", + "red-square" + ], + "🉑": [ + "japanese_acceptable_button", + "ok", + "good", + "chinese", + "kanji", + "agree", + "yes", + "orange-circle" + ], + "🈸": [ + "japanese_application_button", + "chinese", + "japanese", + "kanji", + "orange-square" + ], + "🈴": [ + "japanese_passing_grade_button", + "japanese", + "chinese", + "join", + "kanji", + "red-square" + ], + "🈳": [ + "japanese_vacancy_button", + "kanji", + "japanese", + "chinese", + "empty", + "sky", + "blue-square" + ], + "㊗️": [ + "japanese_congratulations_button", + "chinese", + "kanji", + "japanese", + "red-circle" + ], + "㊙️": [ + "japanese_secret_button", + "privacy", + "chinese", + "sshh", + "kanji", + "red-circle" + ], + "🈺": [ + "japanese_open_for_business_button", + "japanese", + "opening hours", + "orange-square" + ], + "🈵": [ + "japanese_no_vacancy_button", + "full", + "chinese", + "japanese", + "red-square", + "kanji" + ], + "🔴": [ + "red_circle", + "shape", + "error", + "danger" + ], + "🟠": [ + "orange_circle", + "round" + ], + "🟡": [ + "yellow_circle", + "round" + ], + "🟢": [ + "green_circle", + "round" + ], + "🔵": [ + "blue_circle", + "shape", + "icon", + "button" + ], + "🟣": [ + "purple_circle", + "round" + ], + "🟤": [ + "brown_circle", + "round" + ], + "⚫": [ + "black_circle", + "shape", + "button", + "round" + ], + "⚪": [ + "white_circle", + "shape", + "round" + ], + "🟥": [ + "red_square" + ], + "🟧": [ + "orange_square" + ], + "🟨": [ + "yellow_square" + ], + "🟩": [ + "green_square" + ], + "🟦": [ + "blue_square" + ], + "🟪": [ + "purple_square" + ], + "🟫": [ + "brown_square" + ], + "⬛": [ + "black_large_square", + "shape", + "icon", + "button" + ], + "⬜": [ + "white_large_square", + "shape", + "icon", + "stone", + "button" + ], + "◼️": [ + "black_medium_square", + "shape", + "button", + "icon" + ], + "◻️": [ + "white_medium_square", + "shape", + "stone", + "icon" + ], + "◾": [ + "black_medium_small_square", + "icon", + "shape", + "button" + ], + "◽": [ + "white_medium_small_square", + "shape", + "stone", + "icon", + "button" + ], + "▪️": [ + "black_small_square", + "shape", + "icon" + ], + "▫️": [ + "white_small_square", + "shape", + "icon" + ], + "🔶": [ + "large_orange_diamond", + "shape", + "jewel", + "gem" + ], + "🔷": [ + "large_blue_diamond", + "shape", + "jewel", + "gem" + ], + "🔸": [ + "small_orange_diamond", + "shape", + "jewel", + "gem" + ], + "🔹": [ + "small_blue_diamond", + "shape", + "jewel", + "gem" + ], + "🔺": [ + "red_triangle_pointed_up", + "shape", + "direction", + "up", + "top" + ], + "🔻": [ + "red_triangle_pointed_down", + "shape", + "direction", + "bottom" + ], + "💠": [ + "diamond_with_a_dot", + "jewel", + "blue", + "gem", + "crystal", + "fancy" + ], + "🔘": [ + "radio_button", + "input", + "old", + "music", + "circle" + ], + "🔳": [ + "white_square_button", + "shape", + "input" + ], + "🔲": [ + "black_square_button", + "shape", + "input", + "frame" + ], + "🏁": [ + "chequered_flag", + "contest", + "finishline", + "race", + "gokart", + "finish" + ], + "🚩": [ + "triangular_flag", + "mark", + "milestone", + "place" + ], + "🎌": [ + "crossed_flags", + "japanese", + "nation", + "country", + "border" + ], + "🏴": [ + "black_flag", + "pirate" + ], + "🏳️": [ + "white_flag", + "losing", + "loser", + "lost", + "surrender", + "give up", + "fail" + ], + "🏳️‍🌈": [ + "rainbow_flag", + "flag", + "rainbow", + "pride", + "gay", + "lgbt", + "glbt", + "queer", + "homosexual", + "lesbian", + "bisexual", + "transgender" + ], + "🏴‍☠️": [ + "pirate_flag", + "skull", + "crossbones", + "flag", + "banner" + ], + "🇦🇨": [ + "flag_ascension_island" + ], + "🇦🇩": [ + "flag_andorra", + "ad", + "flag", + "nation", + "country", + "banner", + "andorra" + ], + "🇦🇪": [ + "flag_united_arab_emirates", + "united", + "arab", + "emirates", + "flag", + "nation", + "country", + "banner", + "united_arab_emirates" + ], + "🇦🇫": [ + "flag_afghanistan", + "af", + "flag", + "nation", + "country", + "banner", + "afghanistan" + ], + "🇦🇬": [ + "flag_antigua_barbuda", + "antigua", + "barbuda", + "flag", + "nation", + "country", + "banner", + "antigua_barbuda" + ], + "🇦🇮": [ + "flag_anguilla", + "ai", + "flag", + "nation", + "country", + "banner", + "anguilla" + ], + "🇦🇱": [ + "flag_albania", + "al", + "flag", + "nation", + "country", + "banner", + "albania" + ], + "🇦🇲": [ + "flag_armenia", + "am", + "flag", + "nation", + "country", + "banner", + "armenia" + ], + "🇦🇴": [ + "flag_angola", + "ao", + "flag", + "nation", + "country", + "banner", + "angola" + ], + "🇦🇶": [ + "flag_antarctica", + "aq", + "flag", + "nation", + "country", + "banner", + "antarctica" + ], + "🇦🇷": [ + "flag_argentina", + "ar", + "flag", + "nation", + "country", + "banner", + "argentina" + ], + "🇦🇸": [ + "flag_american_samoa", + "american", + "ws", + "flag", + "nation", + "country", + "banner", + "american_samoa" + ], + "🇦🇹": [ + "flag_austria", + "at", + "flag", + "nation", + "country", + "banner", + "austria" + ], + "🇦🇺": [ + "flag_australia", + "au", + "flag", + "nation", + "country", + "banner", + "australia" + ], + "🇦🇼": [ + "flag_aruba", + "aw", + "flag", + "nation", + "country", + "banner", + "aruba" + ], + "🇦🇽": [ + "flag_aland_islands", + "Åland", + "islands", + "flag", + "nation", + "country", + "banner", + "aland_islands" + ], + "🇦🇿": [ + "flag_azerbaijan", + "az", + "flag", + "nation", + "country", + "banner", + "azerbaijan" + ], + "🇧🇦": [ + "flag_bosnia_herzegovina", + "bosnia", + "herzegovina", + "flag", + "nation", + "country", + "banner", + "bosnia_herzegovina" + ], + "🇧🇧": [ + "flag_barbados", + "bb", + "flag", + "nation", + "country", + "banner", + "barbados" + ], + "🇧🇩": [ + "flag_bangladesh", + "bd", + "flag", + "nation", + "country", + "banner", + "bangladesh" + ], + "🇧🇪": [ + "flag_belgium", + "be", + "flag", + "nation", + "country", + "banner", + "belgium" + ], + "🇧🇫": [ + "flag_burkina_faso", + "burkina", + "faso", + "flag", + "nation", + "country", + "banner", + "burkina_faso" + ], + "🇧🇬": [ + "flag_bulgaria", + "bg", + "flag", + "nation", + "country", + "banner", + "bulgaria" + ], + "🇧🇭": [ + "flag_bahrain", + "bh", + "flag", + "nation", + "country", + "banner", + "bahrain" + ], + "🇧🇮": [ + "flag_burundi", + "bi", + "flag", + "nation", + "country", + "banner", + "burundi" + ], + "🇧🇯": [ + "flag_benin", + "bj", + "flag", + "nation", + "country", + "banner", + "benin" + ], + "🇧🇱": [ + "flag_st_barthelemy", + "saint", + "barthélemy", + "flag", + "nation", + "country", + "banner", + "st_barthelemy" + ], + "🇧🇲": [ + "flag_bermuda", + "bm", + "flag", + "nation", + "country", + "banner", + "bermuda" + ], + "🇧🇳": [ + "flag_brunei", + "bn", + "darussalam", + "flag", + "nation", + "country", + "banner", + "brunei" + ], + "🇧🇴": [ + "flag_bolivia", + "bo", + "flag", + "nation", + "country", + "banner", + "bolivia" + ], + "🇧🇶": [ + "flag_caribbean_netherlands", + "bonaire", + "flag", + "nation", + "country", + "banner", + "caribbean_netherlands" + ], + "🇧🇷": [ + "flag_brazil", + "br", + "flag", + "nation", + "country", + "banner", + "brazil" + ], + "🇧🇸": [ + "flag_bahamas", + "bs", + "flag", + "nation", + "country", + "banner", + "bahamas" + ], + "🇧🇹": [ + "flag_bhutan", + "bt", + "flag", + "nation", + "country", + "banner", + "bhutan" + ], + "🇧🇻": [ + "flag_bouvet_island", + "norway" + ], + "🇧🇼": [ + "flag_botswana", + "bw", + "flag", + "nation", + "country", + "banner", + "botswana" + ], + "🇧🇾": [ + "flag_belarus", + "by", + "flag", + "nation", + "country", + "banner", + "belarus" + ], + "🇧🇿": [ + "flag_belize", + "bz", + "flag", + "nation", + "country", + "banner", + "belize" + ], + "🇨🇦": [ + "flag_canada", + "ca", + "flag", + "nation", + "country", + "banner", + "canada" + ], + "🇨🇨": [ + "flag_cocos_islands", + "cocos", + "keeling", + "islands", + "flag", + "nation", + "country", + "banner", + "cocos_islands" + ], + "🇨🇩": [ + "flag_congo_kinshasa", + "congo", + "democratic", + "republic", + "flag", + "nation", + "country", + "banner", + "congo_kinshasa" + ], + "🇨🇫": [ + "flag_central_african_republic", + "central", + "african", + "republic", + "flag", + "nation", + "country", + "banner", + "central_african_republic" + ], + "🇨🇬": [ + "flag_congo_brazzaville", + "congo", + "flag", + "nation", + "country", + "banner", + "congo_brazzaville" + ], + "🇨🇭": [ + "flag_switzerland", + "ch", + "flag", + "nation", + "country", + "banner", + "switzerland" + ], + "🇨🇮": [ + "flag_cote_d_ivoire", + "ivory", + "coast", + "flag", + "nation", + "country", + "banner", + "cote_d_ivoire" + ], + "🇨🇰": [ + "flag_cook_islands", + "cook", + "islands", + "flag", + "nation", + "country", + "banner", + "cook_islands" + ], + "🇨🇱": [ + "flag_chile", + "flag", + "nation", + "country", + "banner", + "chile" + ], + "🇨🇲": [ + "flag_cameroon", + "cm", + "flag", + "nation", + "country", + "banner", + "cameroon" + ], + "🇨🇳": [ + "flag_china", + "china", + "chinese", + "prc", + "flag", + "country", + "nation", + "banner", + "china" + ], + "🇨🇴": [ + "flag_colombia", + "co", + "flag", + "nation", + "country", + "banner", + "colombia" + ], + "🇨🇵": [ + "flag_clipperton_island" + ], + "🇨🇷": [ + "flag_costa_rica", + "costa", + "rica", + "flag", + "nation", + "country", + "banner", + "costa_rica" + ], + "🇨🇺": [ + "flag_cuba", + "cu", + "flag", + "nation", + "country", + "banner", + "cuba" + ], + "🇨🇻": [ + "flag_cape_verde", + "cabo", + "verde", + "flag", + "nation", + "country", + "banner", + "cape_verde" + ], + "🇨🇼": [ + "flag_curacao", + "curaçao", + "flag", + "nation", + "country", + "banner", + "curacao" + ], + "🇨🇽": [ + "flag_christmas_island", + "christmas", + "island", + "flag", + "nation", + "country", + "banner", + "christmas_island" + ], + "🇨🇾": [ + "flag_cyprus", + "cy", + "flag", + "nation", + "country", + "banner", + "cyprus", + "cy" + ], + "🇨🇿": [ + "flag_czechia", + "cz", + "flag", + "nation", + "country", + "banner", + "czechia" + ], + "🇩🇪": [ + "flag_germany", + "german", + "nation", + "flag", + "country", + "banner", + "germany" + ], + "🇩🇬": [ + "flag_diego_garcia" + ], + "🇩🇯": [ + "flag_djibouti", + "dj", + "flag", + "nation", + "country", + "banner", + "djibouti" + ], + "🇩🇰": [ + "flag_denmark", + "dk", + "flag", + "nation", + "country", + "banner", + "denmark" + ], + "🇩🇲": [ + "flag_dominica", + "dm", + "flag", + "nation", + "country", + "banner", + "dominica" + ], + "🇩🇴": [ + "flag_dominican_republic", + "dominican", + "republic", + "flag", + "nation", + "country", + "banner", + "dominican_republic" + ], + "🇩🇿": [ + "flag_algeria", + "dz", + "flag", + "nation", + "country", + "banner", + "algeria" + ], + "🇪🇦": [ + "flag_ceuta_melilla" + ], + "🇪🇨": [ + "flag_ecuador", + "ec", + "flag", + "nation", + "country", + "banner", + "ecuador" + ], + "🇪🇪": [ + "flag_estonia", + "ee", + "flag", + "nation", + "country", + "banner", + "estonia" + ], + "🇪🇬": [ + "flag_egypt", + "eg", + "flag", + "nation", + "country", + "banner", + "egypt" + ], + "🇪🇭": [ + "flag_western_sahara", + "western", + "sahara", + "flag", + "nation", + "country", + "banner", + "western_sahara" + ], + "🇪🇷": [ + "flag_eritrea", + "er", + "flag", + "nation", + "country", + "banner", + "eritrea" + ], + "🇪🇸": [ + "flag_spain", + "spain", + "flag", + "nation", + "country", + "banner", + "spain" + ], + "🇪🇹": [ + "flag_ethiopia", + "et", + "flag", + "nation", + "country", + "banner", + "ethiopia" + ], + "🇪🇺": [ + "flag_european_union", + "european", + "union", + "flag", + "banner" + ], + "🇫🇮": [ + "flag_finland", + "fi", + "flag", + "nation", + "country", + "banner", + "finland" + ], + "🇫🇯": [ + "flag_fiji", + "fj", + "flag", + "nation", + "country", + "banner", + "fiji" + ], + "🇫🇰": [ + "flag_falkland_islands", + "falkland", + "islands", + "malvinas", + "flag", + "nation", + "country", + "banner", + "falkland_islands" + ], + "🇫🇲": [ + "flag_micronesia", + "micronesia", + "federated", + "states", + "flag", + "nation", + "country", + "banner", + "micronesia" + ], + "🇫🇴": [ + "flag_faroe_islands", + "faroe", + "islands", + "flag", + "nation", + "country", + "banner", + "faroe_islands" + ], + "🇫🇷": [ + "flag_france", + "banner", + "flag", + "nation", + "france", + "french", + "country", + "france" + ], + "🇬🇦": [ + "flag_gabon", + "ga", + "flag", + "nation", + "country", + "banner", + "gabon" + ], + "🇬🇧": [ + "flag_united_kingdom", + "united", + "kingdom", + "great", + "britain", + "northern", + "ireland", + "flag", + "nation", + "country", + "banner", + "british", + "UK", + "english", + "england", + "union jack", + "united_kingdom" + ], + "🇬🇩": [ + "flag_grenada", + "gd", + "flag", + "nation", + "country", + "banner", + "grenada" + ], + "🇬🇪": [ + "flag_georgia", + "ge", + "flag", + "nation", + "country", + "banner", + "georgia" + ], + "🇬🇫": [ + "flag_french_guiana", + "french", + "guiana", + "flag", + "nation", + "country", + "banner", + "french_guiana" + ], + "🇬🇬": [ + "flag_guernsey", + "gg", + "flag", + "nation", + "country", + "banner", + "guernsey" + ], + "🇬🇭": [ + "flag_ghana", + "gh", + "flag", + "nation", + "country", + "banner", + "ghana" + ], + "🇬🇮": [ + "flag_gibraltar", + "gi", + "flag", + "nation", + "country", + "banner", + "gibraltar" + ], + "🇬🇱": [ + "flag_greenland", + "gl", + "flag", + "nation", + "country", + "banner", + "greenland" + ], + "🇬🇲": [ + "flag_gambia", + "gm", + "flag", + "nation", + "country", + "banner", + "gambia" + ], + "🇬🇳": [ + "flag_guinea", + "gn", + "flag", + "nation", + "country", + "banner", + "guinea" + ], + "🇬🇵": [ + "flag_guadeloupe", + "gp", + "flag", + "nation", + "country", + "banner", + "guadeloupe" + ], + "🇬🇶": [ + "flag_equatorial_guinea", + "equatorial", + "gn", + "flag", + "nation", + "country", + "banner", + "equatorial_guinea" + ], + "🇬🇷": [ + "flag_greece", + "gr", + "flag", + "nation", + "country", + "banner", + "greece" + ], + "🇬🇸": [ + "flag_south_georgia_south_sandwich_islands", + "south", + "georgia", + "sandwich", + "islands", + "flag", + "nation", + "country", + "banner", + "south_georgia_south_sandwich_islands" + ], + "🇬🇹": [ + "flag_guatemala", + "gt", + "flag", + "nation", + "country", + "banner", + "guatemala" + ], + "🇬🇺": [ + "flag_guam", + "gu", + "flag", + "nation", + "country", + "banner", + "guam" + ], + "🇬🇼": [ + "flag_guinea_bissau", + "gw", + "bissau", + "flag", + "nation", + "country", + "banner", + "guinea_bissau" + ], + "🇬🇾": [ + "flag_guyana", + "gy", + "flag", + "nation", + "country", + "banner", + "guyana" + ], + "🇭🇰": [ + "flag_hong_kong_sar_china", + "hong", + "kong", + "flag", + "nation", + "country", + "banner", + "hong_kong_sar_china" + ], + "🇭🇲": [ + "flag_heard_mcdonald_islands" + ], + "🇭🇳": [ + "flag_honduras", + "hn", + "flag", + "nation", + "country", + "banner", + "honduras" + ], + "🇭🇷": [ + "flag_croatia", + "hr", + "flag", + "nation", + "country", + "banner", + "croatia" + ], + "🇭🇹": [ + "flag_haiti", + "ht", + "flag", + "nation", + "country", + "banner", + "haiti" + ], + "🇭🇺": [ + "flag_hungary", + "hu", + "flag", + "nation", + "country", + "banner", + "hungary" + ], + "🇮🇨": [ + "flag_canary_islands", + "canary", + "islands", + "flag", + "nation", + "country", + "banner", + "canary_islands" + ], + "🇮🇩": [ + "flag_indonesia", + "flag", + "nation", + "country", + "banner", + "indonesia" + ], + "🇮🇪": [ + "flag_ireland", + "ie", + "flag", + "nation", + "country", + "banner", + "ireland" + ], + "🇮🇱": [ + "flag_israel", + "il", + "flag", + "nation", + "country", + "banner", + "israel" + ], + "🇮🇲": [ + "flag_isle_of_man", + "isle", + "man", + "flag", + "nation", + "country", + "banner", + "isle_of_man" + ], + "🇮🇳": [ + "flag_india", + "in", + "flag", + "nation", + "country", + "banner", + "india" + ], + "🇮🇴": [ + "flag_british_indian_ocean_territory", + "british", + "indian", + "ocean", + "territory", + "flag", + "nation", + "country", + "banner", + "british_indian_ocean_territory" + ], + "🇮🇶": [ + "flag_iraq", + "iq", + "flag", + "nation", + "country", + "banner", + "iraq" + ], + "🇮🇷": [ + "flag_iran", + "iran", + "islamic", + "republic", + "flag", + "nation", + "country", + "banner", + "iran" + ], + "🇮🇸": [ + "flag_iceland", + "is", + "flag", + "nation", + "country", + "banner", + "iceland" + ], + "🇮🇹": [ + "flag_italy", + "italy", + "flag", + "nation", + "country", + "banner", + "italy" + ], + "🇯🇪": [ + "flag_jersey", + "je", + "flag", + "nation", + "country", + "banner", + "jersey" + ], + "🇯🇲": [ + "flag_jamaica", + "jm", + "flag", + "nation", + "country", + "banner", + "jamaica" + ], + "🇯🇴": [ + "flag_jordan", + "jo", + "flag", + "nation", + "country", + "banner", + "jordan" + ], + "🇯🇵": [ + "flag_japan", + "japanese", + "nation", + "flag", + "country", + "banner", + "japan", + "jp", + "ja" + ], + "🇰🇪": [ + "flag_kenya", + "ke", + "flag", + "nation", + "country", + "banner", + "kenya" + ], + "🇰🇬": [ + "flag_kyrgyzstan", + "kg", + "flag", + "nation", + "country", + "banner", + "kyrgyzstan" + ], + "🇰🇭": [ + "flag_cambodia", + "kh", + "flag", + "nation", + "country", + "banner", + "cambodia" + ], + "🇰🇮": [ + "flag_kiribati", + "ki", + "flag", + "nation", + "country", + "banner", + "kiribati" + ], + "🇰🇲": [ + "flag_comoros", + "km", + "flag", + "nation", + "country", + "banner", + "comoros" + ], + "🇰🇳": [ + "flag_st_kitts_nevis", + "saint", + "kitts", + "nevis", + "flag", + "nation", + "country", + "banner", + "st_kitts_nevis" + ], + "🇰🇵": [ + "flag_north_korea", + "north", + "korea", + "nation", + "flag", + "country", + "banner", + "north_korea" + ], + "🇰🇷": [ + "flag_south_korea", + "south", + "korea", + "nation", + "flag", + "country", + "banner", + "south_korea" + ], + "🇰🇼": [ + "flag_kuwait", + "kw", + "flag", + "nation", + "country", + "banner", + "kuwait" + ], + "🇰🇾": [ + "flag_cayman_islands", + "cayman", + "islands", + "flag", + "nation", + "country", + "banner", + "cayman_islands" + ], + "🇰🇿": [ + "flag_kazakhstan", + "kz", + "flag", + "nation", + "country", + "banner", + "kazakhstan" + ], + "🇱🇦": [ + "flag_laos", + "lao", + "democratic", + "republic", + "flag", + "nation", + "country", + "banner", + "laos" + ], + "🇱🇧": [ + "flag_lebanon", + "lb", + "flag", + "nation", + "country", + "banner", + "lebanon" + ], + "🇱🇨": [ + "flag_st_lucia", + "saint", + "lucia", + "flag", + "nation", + "country", + "banner", + "st_lucia" + ], + "🇱🇮": [ + "flag_liechtenstein", + "li", + "flag", + "nation", + "country", + "banner", + "liechtenstein" + ], + "🇱🇰": [ + "flag_sri_lanka", + "sri", + "lanka", + "flag", + "nation", + "country", + "banner", + "sri_lanka" + ], + "🇱🇷": [ + "flag_liberia", + "lr", + "flag", + "nation", + "country", + "banner", + "liberia" + ], + "🇱🇸": [ + "flag_lesotho", + "ls", + "flag", + "nation", + "country", + "banner", + "lesotho" + ], + "🇱🇹": [ + "flag_lithuania", + "lt", + "flag", + "nation", + "country", + "banner", + "lithuania" + ], + "🇱🇺": [ + "flag_luxembourg", + "lu", + "flag", + "nation", + "country", + "banner", + "luxembourg" + ], + "🇱🇻": [ + "flag_latvia", + "lv", + "flag", + "nation", + "country", + "banner", + "latvia" + ], + "🇱🇾": [ + "flag_libya", + "ly", + "flag", + "nation", + "country", + "banner", + "libya" + ], + "🇲🇦": [ + "flag_morocco", + "ma", + "flag", + "nation", + "country", + "banner", + "morocco" + ], + "🇲🇨": [ + "flag_monaco", + "mc", + "flag", + "nation", + "country", + "banner", + "monaco" + ], + "🇲🇩": [ + "flag_moldova", + "moldova", + "republic", + "flag", + "nation", + "country", + "banner", + "moldova" + ], + "🇲🇪": [ + "flag_montenegro", + "me", + "flag", + "nation", + "country", + "banner", + "montenegro" + ], + "🇲🇫": [ + "flag_st_martin" + ], + "🇲🇬": [ + "flag_madagascar", + "mg", + "flag", + "nation", + "country", + "banner", + "madagascar" + ], + "🇲🇭": [ + "flag_marshall_islands", + "marshall", + "islands", + "flag", + "nation", + "country", + "banner", + "marshall_islands" + ], + "🇲🇰": [ + "flag_north_macedonia", + "macedonia", + "flag", + "nation", + "country", + "banner", + "north_macedonia" + ], + "🇲🇱": [ + "flag_mali", + "ml", + "flag", + "nation", + "country", + "banner", + "mali" + ], + "🇲🇲": [ + "flag_myanmar", + "mm", + "flag", + "nation", + "country", + "banner", + "myanmar" + ], + "🇲🇳": [ + "flag_mongolia", + "mn", + "flag", + "nation", + "country", + "banner", + "mongolia" + ], + "🇲🇴": [ + "flag_macao_sar_china", + "macao", + "flag", + "nation", + "country", + "banner", + "macao_sar_china" + ], + "🇲🇵": [ + "flag_northern_mariana_islands", + "northern", + "mariana", + "islands", + "flag", + "nation", + "country", + "banner", + "northern_mariana_islands" + ], + "🇲🇶": [ + "flag_martinique", + "mq", + "flag", + "nation", + "country", + "banner", + "martinique" + ], + "🇲🇷": [ + "flag_mauritania", + "mr", + "flag", + "nation", + "country", + "banner", + "mauritania" + ], + "🇲🇸": [ + "flag_montserrat", + "ms", + "flag", + "nation", + "country", + "banner", + "montserrat" + ], + "🇲🇹": [ + "flag_malta", + "mt", + "flag", + "nation", + "country", + "banner", + "malta" + ], + "🇲🇺": [ + "flag_mauritius", + "mu", + "flag", + "nation", + "country", + "banner", + "mauritius" + ], + "🇲🇻": [ + "flag_maldives", + "mv", + "flag", + "nation", + "country", + "banner", + "maldives" + ], + "🇲🇼": [ + "flag_malawi", + "mw", + "flag", + "nation", + "country", + "banner", + "malawi" + ], + "🇲🇽": [ + "flag_mexico", + "mx", + "flag", + "nation", + "country", + "banner", + "mexico" + ], + "🇲🇾": [ + "flag_malaysia", + "my", + "flag", + "nation", + "country", + "banner", + "malaysia" + ], + "🇲🇿": [ + "flag_mozambique", + "mz", + "flag", + "nation", + "country", + "banner", + "mozambique" + ], + "🇳🇦": [ + "flag_namibia", + "na", + "flag", + "nation", + "country", + "banner", + "namibia" + ], + "🇳🇨": [ + "flag_new_caledonia", + "caledonia", + "flag", + "nation", + "country", + "banner", + "new_caledonia" + ], + "🇳🇪": [ + "flag_niger", + "ne", + "flag", + "nation", + "country", + "banner", + "niger" + ], + "🇳🇫": [ + "flag_norfolk_island", + "norfolk", + "island", + "flag", + "nation", + "country", + "banner", + "norfolk_island" + ], + "🇳🇬": [ + "flag_nigeria", + "flag", + "nation", + "country", + "banner", + "nigeria" + ], + "🇳🇮": [ + "flag_nicaragua", + "ni", + "flag", + "nation", + "country", + "banner", + "nicaragua" + ], + "🇳🇱": [ + "flag_netherlands", + "nl", + "flag", + "nation", + "country", + "banner", + "netherlands" + ], + "🇳🇴": [ + "flag_norway", + "no", + "flag", + "nation", + "country", + "banner", + "norway" + ], + "🇳🇵": [ + "flag_nepal", + "np", + "flag", + "nation", + "country", + "banner", + "nepal" + ], + "🇳🇷": [ + "flag_nauru", + "nr", + "flag", + "nation", + "country", + "banner", + "nauru" + ], + "🇳🇺": [ + "flag_niue", + "nu", + "flag", + "nation", + "country", + "banner", + "niue" + ], + "🇳🇿": [ + "flag_new_zealand", + "zealand", + "flag", + "nation", + "country", + "banner", + "new_zealand" + ], + "🇴🇲": [ + "flag_oman", + "om_symbol", + "flag", + "nation", + "country", + "banner", + "oman" + ], + "🇵🇦": [ + "flag_panama", + "pa", + "flag", + "nation", + "country", + "banner", + "panama" + ], + "🇵🇪": [ + "flag_peru", + "pe", + "flag", + "nation", + "country", + "banner", + "peru" + ], + "🇵🇫": [ + "flag_french_polynesia", + "french", + "polynesia", + "flag", + "nation", + "country", + "banner", + "french_polynesia" + ], + "🇵🇬": [ + "flag_papua_new_guinea", + "papua", + "guinea", + "flag", + "nation", + "country", + "banner", + "papua_new_guinea" + ], + "🇵🇭": [ + "flag_philippines", + "ph", + "flag", + "nation", + "country", + "banner", + "philippines" + ], + "🇵🇰": [ + "flag_pakistan", + "pk", + "flag", + "nation", + "country", + "banner", + "pakistan" + ], + "🇵🇱": [ + "flag_poland", + "flag", + "nation", + "country", + "banner", + "poland", + "pl" + ], + "🇵🇲": [ + "flag_st_pierre_miquelon", + "saint", + "pierre", + "miquelon", + "flag", + "nation", + "country", + "banner", + "st_pierre_miquelon" + ], + "🇵🇳": [ + "flag_pitcairn_islands", + "pitcairn", + "flag", + "nation", + "country", + "banner", + "pitcairn_islands" + ], + "🇵🇷": [ + "flag_puerto_rico", + "puerto", + "rico", + "flag", + "nation", + "country", + "banner", + "puerto_rico" + ], + "🇵🇸": [ + "flag_palestinian_territories", + "palestine", + "palestinian", + "territories", + "flag", + "nation", + "country", + "banner", + "palestinian_territories" + ], + "🇵🇹": [ + "flag_portugal", + "pt", + "flag", + "nation", + "country", + "banner", + "portugal" + ], + "🇵🇼": [ + "flag_palau", + "pw", + "flag", + "nation", + "country", + "banner", + "palau" + ], + "🇵🇾": [ + "flag_paraguay", + "py", + "flag", + "nation", + "country", + "banner", + "paraguay" + ], + "🇶🇦": [ + "flag_qatar", + "qa", + "flag", + "nation", + "country", + "banner", + "qatar" + ], + "🇷🇪": [ + "flag_reunion", + "réunion", + "flag", + "nation", + "country", + "banner", + "reunion" + ], + "🇷🇴": [ + "flag_romania", + "ro", + "flag", + "nation", + "country", + "banner", + "romania" + ], + "🇷🇸": [ + "flag_serbia", + "rs", + "flag", + "nation", + "country", + "banner", + "serbia", + "srb" + ], + "🇷🇺": [ + "flag_russia", + "russian", + "federation", + "flag", + "nation", + "country", + "banner", + "russia" + ], + "🇷🇼": [ + "flag_rwanda", + "rw", + "flag", + "nation", + "country", + "banner", + "rwanda" + ], + "🇸🇦": [ + "flag_saudi_arabia", + "flag", + "nation", + "country", + "banner", + "saudi_arabia" + ], + "🇸🇧": [ + "flag_solomon_islands", + "solomon", + "islands", + "flag", + "nation", + "country", + "banner", + "solomon_islands" + ], + "🇸🇨": [ + "flag_seychelles", + "sc", + "flag", + "nation", + "country", + "banner", + "seychelles" + ], + "🇸🇩": [ + "flag_sudan", + "sd", + "flag", + "nation", + "country", + "banner", + "sudan" + ], + "🇸🇪": [ + "flag_sweden", + "se", + "flag", + "nation", + "country", + "banner", + "sweden" + ], + "🇸🇬": [ + "flag_singapore", + "sg", + "flag", + "nation", + "country", + "banner", + "singapore" + ], + "🇸🇭": [ + "flag_st_helena", + "saint", + "helena", + "ascension", + "tristan", + "cunha", + "flag", + "nation", + "country", + "banner", + "st_helena" + ], + "🇸🇮": [ + "flag_slovenia", + "si", + "flag", + "nation", + "country", + "banner", + "slovenia" + ], + "🇸🇯": [ + "flag_svalbard_jan_mayen" + ], + "🇸🇰": [ + "flag_slovakia", + "sk", + "flag", + "nation", + "country", + "banner", + "slovakia" + ], + "🇸🇱": [ + "flag_sierra_leone", + "sierra", + "leone", + "flag", + "nation", + "country", + "banner", + "sierra_leone" + ], + "🇸🇲": [ + "flag_san_marino", + "san", + "marino", + "flag", + "nation", + "country", + "banner", + "san_marino" + ], + "🇸🇳": [ + "flag_senegal", + "sn", + "flag", + "nation", + "country", + "banner", + "senegal" + ], + "🇸🇴": [ + "flag_somalia", + "so", + "flag", + "nation", + "country", + "banner", + "somalia" + ], + "🇸🇷": [ + "flag_suriname", + "sr", + "flag", + "nation", + "country", + "banner", + "suriname" + ], + "🇸🇸": [ + "flag_south_sudan", + "south", + "sd", + "flag", + "nation", + "country", + "banner", + "south_sudan" + ], + "🇸🇹": [ + "flag_sao_tome_principe", + "sao", + "tome", + "principe", + "flag", + "nation", + "country", + "banner", + "sao_tome_principe" + ], + "🇸🇻": [ + "flag_el_salvador", + "el", + "salvador", + "flag", + "nation", + "country", + "banner", + "el_salvador" + ], + "🇸🇽": [ + "flag_sint_maarten", + "sint", + "maarten", + "dutch", + "flag", + "nation", + "country", + "banner", + "sint_maarten" + ], + "🇸🇾": [ + "flag_syria", + "syrian", + "arab", + "republic", + "flag", + "nation", + "country", + "banner", + "syria" + ], + "🇸🇿": [ + "flag_eswatini", + "sz", + "flag", + "nation", + "country", + "banner", + "eswatini" + ], + "🇹🇦": [ + "flag_tristan_da_cunha" + ], + "🇹🇨": [ + "flag_turks_caicos_islands", + "turks", + "caicos", + "islands", + "flag", + "nation", + "country", + "banner", + "turks_caicos_islands" + ], + "🇹🇩": [ + "flag_chad", + "td", + "flag", + "nation", + "country", + "banner", + "chad" + ], + "🇹🇫": [ + "flag_french_southern_territories", + "french", + "southern", + "territories", + "flag", + "nation", + "country", + "banner", + "french_southern_territories" + ], + "🇹🇬": [ + "flag_togo", + "tg", + "flag", + "nation", + "country", + "banner", + "togo" + ], + "🇹🇭": [ + "flag_thailand", + "th", + "flag", + "nation", + "country", + "banner", + "thailand" + ], + "🇹🇯": [ + "flag_tajikistan", + "tj", + "flag", + "nation", + "country", + "banner", + "tajikistan" + ], + "🇹🇰": [ + "flag_tokelau", + "tk", + "flag", + "nation", + "country", + "banner", + "tokelau" + ], + "🇹🇱": [ + "flag_timor_leste", + "timor", + "leste", + "flag", + "nation", + "country", + "banner", + "timor_leste" + ], + "🇹🇲": [ + "flag_turkmenistan", + "flag", + "nation", + "country", + "banner", + "turkmenistan" + ], + "🇹🇳": [ + "flag_tunisia", + "tn", + "flag", + "nation", + "country", + "banner", + "tunisia" + ], + "🇹🇴": [ + "flag_tonga", + "flag", + "nation", + "country", + "banner", + "tonga" + ], + "🇹🇷": [ + "flag_turkey", + "turkey", + "flag", + "nation", + "country", + "banner", + "turkey" + ], + "🇹🇹": [ + "flag_trinidad_tobago", + "trinidad", + "tobago", + "flag", + "nation", + "country", + "banner", + "trinidad_tobago" + ], + "🇹🇻": [ + "flag_tuvalu", + "flag", + "nation", + "country", + "banner", + "tuvalu" + ], + "🇹🇼": [ + "flag_taiwan", + "tw", + "flag", + "nation", + "country", + "banner", + "taiwan" + ], + "🇹🇿": [ + "flag_tanzania", + "tanzania", + "united", + "republic", + "flag", + "nation", + "country", + "banner", + "tanzania" + ], + "🇺🇦": [ + "flag_ukraine", + "ua", + "flag", + "nation", + "country", + "banner", + "ukraine", + "ua" + ], + "🇺🇬": [ + "flag_uganda", + "ug", + "flag", + "nation", + "country", + "banner", + "uganda" + ], + "🇺🇲": [ + "flag_u_s_outlying_islands" + ], + "🇺🇳": [ + "flag_united_nations", + "un", + "flag", + "banner" + ], + "🇺🇸": [ + "flag_united_states", + "united", + "states", + "america", + "us", + "flag", + "nation", + "country", + "banner", + "united_states" + ], + "🇺🇾": [ + "flag_uruguay", + "uy", + "flag", + "nation", + "country", + "banner", + "uruguay" + ], + "🇺🇿": [ + "flag_uzbekistan", + "uz", + "flag", + "nation", + "country", + "banner", + "uzbekistan" + ], + "🇻🇦": [ + "flag_vatican_city", + "vatican", + "city", + "flag", + "nation", + "country", + "banner", + "vatican_city" + ], + "🇻🇨": [ + "flag_st_vincent_grenadines", + "saint", + "vincent", + "grenadines", + "flag", + "nation", + "country", + "banner", + "st_vincent_grenadines" + ], + "🇻🇪": [ + "flag_venezuela", + "ve", + "bolivarian", + "republic", + "flag", + "nation", + "country", + "banner", + "venezuela" + ], + "🇻🇬": [ + "flag_british_virgin_islands", + "british", + "virgin", + "islands", + "bvi", + "flag", + "nation", + "country", + "banner", + "british_virgin_islands" + ], + "🇻🇮": [ + "flag_u_s_virgin_islands", + "virgin", + "islands", + "flag", + "nation", + "country", + "banner", + "u_s_virgin_islands" + ], + "🇻🇳": [ + "flag_vietnam", + "viet", + "nam", + "flag", + "nation", + "country", + "banner", + "vietnam" + ], + "🇻🇺": [ + "flag_vanuatu", + "vu", + "flag", + "nation", + "country", + "banner", + "vanuatu" + ], + "🇼🇫": [ + "flag_wallis_futuna", + "wallis", + "futuna", + "flag", + "nation", + "country", + "banner", + "wallis_futuna" + ], + "🇼🇸": [ + "flag_samoa", + "ws", + "flag", + "nation", + "country", + "banner", + "samoa" + ], + "🇽🇰": [ + "flag_kosovo", + "xk", + "flag", + "nation", + "country", + "banner", + "kosovo" + ], + "🇾🇪": [ + "flag_yemen", + "ye", + "flag", + "nation", + "country", + "banner", + "yemen" + ], + "🇾🇹": [ + "flag_mayotte", + "yt", + "flag", + "nation", + "country", + "banner", + "mayotte" + ], + "🇿🇦": [ + "flag_south_africa", + "south", + "africa", + "flag", + "nation", + "country", + "banner", + "south_africa" + ], + "🇿🇲": [ + "flag_zambia", + "zm", + "flag", + "nation", + "country", + "banner", + "zambia" + ], + "🇿🇼": [ + "flag_zimbabwe", + "zw", + "flag", + "nation", + "country", + "banner", + "zimbabwe" + ], + "🏴󠁧󠁢󠁥󠁮󠁧󠁿": [ + "flag_england", + "flag", + "english" + ], + "🏴󠁧󠁢󠁳󠁣󠁴󠁿": [ + "flag_scotland", + "flag", + "scottish" + ], + "🏴󠁧󠁢󠁷󠁬󠁳󠁿": [ + "flag_wales", + "flag", + "welsh" + ], + "🤵‍♂️": [ + "man in tuxedo", + "formal", + "fashion" + ], + "🤵‍♀️": [ + "woman in tuxedo", + "formal", + "fashion" + ], + "👰‍♂️": [ + "man with veil", + "wedding", + "marriage" + ], + "👰‍♀️": [ + "woman with veil", + "wedding", + "marriage" + ], + "👩‍🍼": [ + "woman feeding baby", + "birth", + "food" + ], + "👨‍🍼": [ + "man feeding baby", + "birth", + "food" + ], + "🧑‍🍼": [ + "person feeding baby", + "birth", + "food" + ], + "🧑‍🎄": [ + "mx claus", + "christmas" + ], + "🫂": [ + "people hugging", + "care" + ], + "🐈‍⬛": [ + "black cat", + "superstition", + "luck" + ], + "🦬": [ + "bison", + "ox" + ], + "🦣": [ + "mammoth", + "elephant", + "tusks" + ], + "🦫": [ + "beaver", + "animal", + "rodent" + ], + "🐻‍❄️": [ + "polar bear", + "animal", + "arctic" + ], + "⚧️": [ + "transgender symbol", + "lgbtq" + ], + "🏳️‍⚧️": [ + "transgender flag", + "lgbtq" + ], + "😶‍🌫️": [ + "face in clouds", + "shower", + "steam", + "dream" + ], + "😮‍💨": [ + "face exhaling", + "relieve", + "relief", + "tired", + "sigh" + ], + "😵‍💫": [ + "face with spiral eyes", + "sick", + "ill", + "confused", + "nauseous", + "nausea" + ], + "❤️‍🔥": [ + "heart on fire", + "passionate", + "enthusiastic" + ], + "❤️‍🩹": [ + "mending heart", + "broken heart", + "bandage", + "wounded" + ], + "🧔‍♂️": [ + "man beard", + "facial hair" + ], + "🧔‍♀️": [ + "woman beard", + "facial hair" + ] +} \ No newline at end of file diff --git a/server/i18n/ru.json b/server/i18n/ru.json new file mode 100644 index 0000000..1ce17a8 --- /dev/null +++ b/server/i18n/ru.json @@ -0,0 +1,75 @@ +{ + "🏠️ *:completed*|:todo tasks for today:": "🏠️ Задачи на сегодня: *:completed*|:todo", + "*:todo* left": "*:todo* осталось", + "*:todo* tasks for today": "Задач на сегодня: *:todo*", + "⏳ Tasks for later": "⏳ Задачи на потом", + "🏠 Tasks for today": "🏠 Задачи на сегодня", + "You don't have any tasks!": "У тебя нет задач!", + "⏳ Your tasks for *later*:": "⏳ Задачи на *потом*:", + "🏠 Home": "🏠️ Домой", + "🔙 Back to today": "🔙 В список на сегодня", + "Added to *:path* 👌!": "Добавил к *:path* 👌!", + "Added to :path 👌!": "Добавил к :path 👌!", + "📥 Saved to *:dir*. Change the dir?": "📥 Сохранил во *входящие*. Изменить папку?", + ":icon To :dir": ":icon В :dir", + "📌 To Note": "📌 В заметку", + "☑️ To Checklist": "☑️ В чеклист", + "📝 To Doc": "📝 В документ", + "📝 To File": "📝 В файл", + "📝 Docs": "📝 Документы", + "⬅️ Docs": "⬅️ Документы", + "📝 Your docs:": "📝 Ваши документы:", + "☑️ Checklists": "☑️ Чеклисты", + "📝 Choose a doc or name a new one:": "📝 Выберите документ или отправьте мне имя для нового документа:", + "💾 Choose a doc or name a new one:": "💾 Выберите документ или отправьте мне имя для нового документаллл:", + "☑️ Choose a list or name a new one:": "☑️ Выберите лист или отправьте мне имя для нового списка:", + "🗂 Choose a dir or name a new one:": "Выберите папку или отправьте мне имя для новой папки:", + "💾 Saved to :dir!": "💾 Сохранил в :dir!", + "📝 New file *:file* created!": "📝 Новый файл *:file* создан!", + "🗂 New dir *:dir* created!": "🗂 Новая папка *:dir* создана!", + "☑️ New list *:list* created!": "☑️ Новый лист *:list* создан!", + "Got it 👌": "Запомнил 👌", + "🏗 Your *:dir*:": "🏗 Папка *:dir*:", + "⏳ Later": "⏳ Потом", + "Task saved for *today*!": "Задача сохранена на *сегодня*!", + "💾 Saved to *:path* 👌!": "💾 Сохранил в *:path* 👌!", + "Go to Today": "Перейти в Сегодня", + "🎉 :task completed!": "🎉 Задача \":task\" завершена!", + "🌚 For tmrw" : "🌚️ На завтра", + "⏳ For later": "⏳ На потом", + "📆 For a day": "📆 На день", + "➡️ Today": "➡️ Сегодня", + "➡️ Move to today": "➡️ Переместить на сегодня", + "➡️ Moved to today!": "➡️ Перемещено на сегодня!", + "🌝️ Moved to tomorrow!": "🌝️ Перемещено на завтра!", + "🏠 Moved to today!": "🏠️ Перемещено на сегодня!", + "⏳ Move to later": "⏳ Переместить на потом", + "⏳ Moved to later!": "⏳ Перемещено на потом!", + "💚 To Journal": "💚 В журнал", + "⬅️ Back": "⬅️ Назад", + "✅ Complete": "✅ Завершить", + "🗒 Notes": "🗒 Заметки", + "🔄 :count recurring task(s)": "🔄 Повторяющиеся задач: :count", + "📊 :count tasks done in total": "📊 Всего задач сделано: :count", + "✨️ :count action(s)": "✨️ Действий: :count", + "👀 Choose a dir to read": "👀 Что будем читать?", + "Too short, minimum length: 1 character 😔!": "Длина задачи должна быть более 1 символа 😔!", + "🔥 Tasks you've done today:\n\n:list": + "🔥 Задачи, выполненные за сегодня:\n\n:list", + "🎉 You've got a medal: :medal!": "🎉 Вы получили медаль: :medal!", + "😎 You are more productive than :percent% of users!": "😎 Вы продуктивнее :percent% юзеров!", + "📚 Read": "📚 Почитать", + "📺 Watch": "📺 Посмотреть", + "🌎 Places": "🌎 Места", + "🏗 Projects": "🏗 Проекты", + "📥 Inbox": "📥 Входящие", + "🗓 Week": "🗓 Неделя", + "☑️ Completed": "☑️ Завершенные", + "🏠️ To today": "🏠️ На сегодня", + "⏳ To later": "⏳ На потом", + "🗑 Delete": "🗑 Удалить", + "✉️️ *Send me the new text for:*": "✉️️ *Пришли мне новый текст задачи:*", + "✏️ Select a task to edit:": "✏️ Выбери задачу для редактирования:", + "🏠️ I'm done here!": "🏠️ Я всё!", + "🦥 Select a task to postpone:": "🦥 Какую задачу отложить на потом?" +} \ No newline at end of file diff --git a/server/i18n/strings.go b/server/i18n/strings.go new file mode 100644 index 0000000..856b920 --- /dev/null +++ b/server/i18n/strings.go @@ -0,0 +1,40 @@ +package i18n + +// How many refs per every constant? Maybe we can leave literal constants +const ( + StrLater = "⏳" + StrHome = "🏠 Home" + StrBack = "⬅️ Back" + StrComplete = "✅ Complete" + StrMoveToLaterLong = "⏳ Move to later" + StrToToday = "➡️ Move to today" + StrToTomorrow = "🌚 To tmrw" + StrToLater = "⏳ To later" + StrToADay = "📆 To a day" + StrToChecklist = "☑️ To Checklist" + StrToFile = "📄 To File" + StrToJournal = "💚 To Journal" + StrToRead = "📚 To Read" + StrToShop = "🛒 To Shop" + StrToWatch = "📺 To Watch" + StrGoToToday = "➡️ Today" + StrRepeat = "🔄️ Repeat the task" + StrQuickBtns = "⚡️ Quick buttons" + StrMoveToBtns = "➡️ Move to buttons" + + StrMonday = "Mon" + StrTuesday = "Tue" + StrWednesday = "Wed" + StrThursday = "Thu" + StrFriday = "Fri" + StrSaturday = "Sat" + StrSunday = "Sun" + StrWeekdays = "Weekdays" + StrEveryday = "Every day" +) + +var PomodoroStarted = "Pomodoro is started: you can see Finished a break task in your task list. Once are ready to focus on something and start working, just complete this task. It will get back in 50 minutes to let you know that you worked enough and deserved a break." + +func Tr(str string) string { + return str +} diff --git a/server/journal/journal.go b/server/journal/journal.go new file mode 100644 index 0000000..7e26b4c --- /dev/null +++ b/server/journal/journal.go @@ -0,0 +1,152 @@ +package journal + +import ( + "fmt" + "regexp" + "slices" + "strings" + "sync" + "time" + + "github.com/zakirullin/files.md/server/fs" + "github.com/zakirullin/files.md/server/habits" + "github.com/zakirullin/files.md/server/pkg/txt" +) + +var ( + Now = time.Now + mu sync.Mutex + userLocks map[string]*sync.Mutex +) + +// AddRecord adds a record for the current day. +// Creates a file if there's no one for the current month +func AddRecord(userFS *fs.FS, record string, timezone *time.Location) error { + key, err := userFS.SafePath(fs.DirUserRoot, "") + if err != nil { + return fmt.Errorf("failed to get safe path: %w", err) + } + + lock := userLock(key) + lock.Lock() + defer lock.Unlock() + + record = strings.TrimSpace(record) + journalFilename := todayJournalFilename(timezone) + exists, err := userFS.Exists(fs.DirJournal, journalFilename) + if err != nil { + return err + } + + var md string + if exists { + md, err = userFS.Read(fs.DirJournal, journalFilename) + if err != nil { + return err + } + md = txt.NormNewLines(md) + md = strings.TrimSpace(md) + if len(md) != 0 { + md += "\n" + } + } + + if !strings.Contains(md, todayHeader(timezone)) { + md += todayHeader(timezone) + "\n" + } + + timestamp := Now().In(timezone).Format("`15:04`") + if txt.HasImage(record) { + // If there's an image - place timestamp under the image + re := regexp.MustCompile(txt.ImgPattern) + imgLink := re.FindString(record) + record = strings.TrimSpace(strings.Replace(record, imgLink, "", 1)) + record = fmt.Sprintf("%s\n%s %s\n", imgLink, timestamp, strings.TrimSpace(record)) + } else { + record = fmt.Sprintf("%s %s\n", Now().In(timezone).Format("`15:04`"), record) + } + + md += record + + return userFS.Write(fs.DirJournal, journalFilename, md) +} + +// AddEmoji adds an emoji to the current day's record +// Creates a file if there's no one for the current month +func AddEmoji(userFS *fs.FS, emoji string, timezone *time.Location) error { + if len(emoji) == 0 { + return nil + } + + key, err := userFS.SafePath(fs.DirUserRoot, "") + if err != nil { + return fmt.Errorf("failed to get safe path: %w", err) + } + + lock := userLock(key) + lock.Lock() + defer lock.Unlock() + + journalFilename := todayJournalFilename(timezone) + exists, err := userFS.Exists(fs.DirJournal, journalFilename) + if err != nil { + return err + } + + if !exists { + md := fmt.Sprintf("%s %s", todayHeader(timezone), emoji) + return userFS.Write(fs.DirJournal, journalFilename, md) + } + + md, err := userFS.Read(fs.DirJournal, journalFilename) + if err != nil { + return err + } + md = txt.NormNewLines(md) + md = strings.TrimSpace(md) + + todayHeaderRE := regexp.MustCompile(fmt.Sprintf(`(%s) *(.*)`, todayHeader(timezone))) + if todayHeaderRE.MatchString(md) { + replacement := fmt.Sprintf(`$1 ${2}%s`, emoji) + // Prepend day's mood emoji in front of all other emojis + if slices.Contains(habits.MoodEmojis, emoji) { + replacement = fmt.Sprintf(`$1 %s${2}`, emoji) + } + md = todayHeaderRE.ReplaceAllString(md, replacement) + } else { + md += fmt.Sprintf("\n%s %s", todayHeader(timezone), emoji) + } + + err = userFS.Write(fs.DirJournal, journalFilename, md) + if err != nil { + return fmt.Errorf("failed to write to journal: %w", err) + } + + return nil +} + +func todayJournalFilename(timezone *time.Location) string { + return Now().In(timezone).Format("2006.01 January.md") +} + +func todayHeader(timezone *time.Location) string { + nowTZ := Now().In(timezone) + return fmt.Sprintf("## %d %s, %s", nowTZ.Day(), nowTZ.Format("January"), nowTZ.Weekday()) +} + +func userLock(rootPath string) *sync.Mutex { + mu.Lock() + defer mu.Unlock() + + if userLocks == nil { + userLocks = make(map[string]*sync.Mutex) + } + if lock, exists := userLocks[rootPath]; exists { + return lock + } + + newLock := &sync.Mutex{} + userLocks[rootPath] = newLock + + return newLock +} diff --git a/server/journal/journal_test.go b/server/journal/journal_test.go new file mode 100644 index 0000000..4ad7af2 --- /dev/null +++ b/server/journal/journal_test.go @@ -0,0 +1,216 @@ +package journal + +import ( + "testing" + "time" + + "github.com/zakirullin/files.md/server/fs" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestAddRecord(t *testing.T) { + r := require.New(t) + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2023, 0o5, 30, 10, 0o4, 36, 0, time.UTC) + } + + type testcase struct { + name string + md string + record string + want string + } + + tests := []testcase{ + { + name: "Empty MD", + record: "note 1", + want: "## 30 May, Tuesday\n`10:04` note 1\n", + }, + { + name: "No Headers", + md: "some text", + record: "note 1", + want: "some text\n## 30 May, Tuesday\n`10:04` note 1\n", + }, + { + name: "Bare header", + md: "## 30 May, Tuesday\n", + record: "note 1", + want: "## 30 May, Tuesday\n`10:04` note 1\n", + }, + { + name: "New daily note", + md: "## 29 May, Tuesday\nnote 1", + record: "note 2", + want: "## 29 May, Tuesday\nnote 1\n## 30 May, Tuesday\n`10:04` note 2\n", + }, + { + name: "Append daily note", + md: "## 29 May, Tuesday\nnote 1\n## 30 May, Tuesday\nnote 2", + record: "note 3", + want: "## 29 May, Tuesday\nnote 1\n## 30 May, Tuesday\nnote 2\n`10:04` note 3\n", + }, + { + name: "Image without caption", + md: "some text", + record: "![](img/tg_HASH.jpg|)", + want: "some text\n## 30 May, Tuesday\n![](img/tg_HASH.jpg|)\n`10:04` \n", + }, + { + name: "Image with caption", + md: "some text", + record: "![](img/tg_HASH.jpg)\nCaption", + want: "some text\n## 30 May, Tuesday\n![](img/tg_HASH.jpg)\n`10:04` Caption\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + err = userFS.Write(fs.DirJournal, "2023.05 May.md", test.md) + r.NoError(err) + + err = AddRecord(userFS, test.record, time.UTC) + r.NoError(err) + + md, err := userFS.Read(fs.DirJournal, "2023.05 May.md") + r.NoError(err) + r.Equal(test.want, md) + }) + } +} + +func TestAddEmojiNewFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2024, time.January, 1, 1, 0, 0, 0, time.UTC) + } + + err = AddEmoji(userFS, "🙂", time.UTC) + r.NoError(err) + + content, err := userFS.Read("journal", "2024.01 January.md") + r.NoError(err) + + r.Equal("## 1 January, Monday 🙂", content) +} + +func TestAddEmojiExistingFile(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + md := "## 0, Sunday\nSome Note\n## 1 January, Monday\nSome Note" + err = userFS.Write("journal", "2024.01 January.md", md) + r.NoError(err) + + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2024, time.January, 1, 1, 0, 0, 0, time.UTC) + } + + err = AddEmoji(userFS, "🙂", time.UTC) + r.NoError(err) + + content, err := userFS.Read("journal", "2024.01 January.md") + r.NoError(err) + + r.Equal("## 0, Sunday\nSome Note\n## 1 January, Monday 🙂\nSome Note", content) +} + +func TestAddEmojiExistingFileMissingDay(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + md := "## 0, Sunday\nSome Note\n" + err = userFS.Write("journal", "2024.01 January.md", md) + r.NoError(err) + + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2024, time.January, 1, 1, 0, 0, 0, time.UTC) + } + + err = AddEmoji(userFS, "🙂", time.UTC) + r.NoError(err) + + content, err := userFS.Read("journal", "2024.01 January.md") + r.NoError(err) + + r.Equal("## 0, Sunday\nSome Note\n## 1 January, Monday 🙂", content) +} + +func TestAddMoodEmojiExistingFileExistingEmojis(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + md := "## 0, Sunday\nSome Note\n## 1 January, Monday 🌱📵\nSome Note" + err = userFS.Write("journal", "2024.01 January.md", md) + r.NoError(err) + + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2024, time.January, 1, 1, 0, 0, 0, time.UTC) + } + + err = AddEmoji(userFS, "🙂", time.UTC) + r.NoError(err) + + content, err := userFS.Read("journal", "2024.01 January.md") + r.NoError(err) + + r.Equal("## 0, Sunday\nSome Note\n## 1 January, Monday 🙂🌱📵\nSome Note", content) +} + +func TestAddRegularEmojiExistingFileExistingEmojis(t *testing.T) { + r := require.New(t) + + userFS, err := fs.NewFS("/", afero.NewMemMapFs()) + r.NoError(err) + md := "## 0, Sunday\nSome Note\n## 1 January, Monday 🌱📵\nSome Note" + err = userFS.Write("journal", "2024.01 January.md", md) + r.NoError(err) + + savedNow := Now + defer func() { + Now = savedNow + }() + Now = func() time.Time { + return time.Date(2024, time.January, 1, 1, 0, 0, 0, time.UTC) + } + + err = AddEmoji(userFS, "🎃", time.UTC) + r.NoError(err) + + content, err := userFS.Read("journal", "2024.01 January.md") + r.NoError(err) + + r.Equal("## 0, Sunday\nSome Note\n## 1 January, Monday 🌱📵🎃\nSome Note", content) +} diff --git a/server/pkg/slice/slice.go b/server/pkg/slice/slice.go new file mode 100644 index 0000000..0cf2cc1 --- /dev/null +++ b/server/pkg/slice/slice.go @@ -0,0 +1,16 @@ +package slice + +// Chunk splits items into chunkSize-d chunks +// Chunk([1,2,3], 2) => [[1,2], [3] +func Chunk[T any](items []T, chunkSize int) [][]T { + if len(items) == 0 { + return [][]T{} + } + + var chunks [][]T + for len(items) > chunkSize { + items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize]) + } + + return append(chunks, items) +} diff --git a/server/pkg/slice/slice_test.go b/server/pkg/slice/slice_test.go new file mode 100644 index 0000000..5357975 --- /dev/null +++ b/server/pkg/slice/slice_test.go @@ -0,0 +1,42 @@ +package slice + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmallerChunk(t *testing.T) { + // Test case 1: Chunk size is smaller than the length of the items slice + items := []int{1, 2, 3, 4, 5, 6} + chunkSize := 2 + expectedChunks := [][]int{ + {1, 2}, + {3, 4}, + {5, 6}, + } + chunks := Chunk(items, chunkSize) + if !reflect.DeepEqual(chunks, expectedChunks) { + t.Errorf("Test case 1 failed. Expected chunks: %v, got: %v", expectedChunks, chunks) + } +} + +func TestLargerChunk(t *testing.T) { + r := require.New(t) + r.Equal([][]int{{1, 2, 3, 4, 5, 6}}, Chunk([]int{1, 2, 3, 4, 5, 6}, 10)) +} + +func TestEqualChunk(t *testing.T) { + r := require.New(t) + + items := []int{1, 2, 3, 4, 5, 6} + chunkSize := len(items) + chunks := Chunk(items, chunkSize) + r.Equal([][]int{{1, 2, 3, 4, 5, 6}}, chunks) +} + +func TestChunkEmpty(t *testing.T) { + r := require.New(t) + r.Equal([][]int{}, Chunk([]int{}, 2)) +} diff --git a/server/pkg/tg/tg.go b/server/pkg/tg/tg.go new file mode 100644 index 0000000..f4f7060 --- /dev/null +++ b/server/pkg/tg/tg.go @@ -0,0 +1,309 @@ +package tg + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +const ( + MarkupHTML = "Html" +) + +// ErrFileTooBig is returned when a file exceeds Telegram's hard limit on +// bot downloads (20MB via the hosted Bot API). +var ErrFileTooBig = errors.New("file is too big for Telegram bots (20MB max)") + +// TG is a simple wrapper over Telegram API. +// It can send/edit messages. Keyboard can be attached. +type TG struct { + api *tgbotapi.BotAPI +} + +func NewTG(api *tgbotapi.BotAPI) *TG { + return &TG{api} +} + +func (tg *TG) SendHTML(userID int64, text string, kb *Keyboard) (int, error) { + return tg.Send(userID, text, kb, MarkupHTML) +} + +func (tg *TG) Send(userID int64, text string, kb *Keyboard, markup string) (int, error) { + msg := tgbotapi.NewMessage(userID, text) + msg.ReplyMarkup = tg.buildInlineKeyboard(kb) + msg.ParseMode = markup + + resp, err := tg.api.Send(msg) + if err != nil { + js, _ := json.Marshal(msg) + return 0, fmt.Errorf("tg send: can't send json %s: %w", js, err) + } + + return resp.MessageID, nil +} + +func (tg *TG) SendImages(userID int64, photos []string) ([]int, error) { + var photoFiles []any + var documentFiles []any + for _, img := range photos { + // It seems like file_ids of images begin with "AgACA" pattern. + // I don't want to do extra getFileMeta request. + isPhoto := strings.HasPrefix(img, "AgACA") + if isPhoto { + photoFiles = append(photoFiles, tgbotapi.NewInputMediaPhoto(tgbotapi.FileID(img))) + } else { + documentFiles = append(documentFiles, tgbotapi.NewInputMediaDocument(tgbotapi.FileID(img))) + } + } + + // Sending by groups. We send all photos as one group, and the documents as another group + var msgIDs []int + if len(photoFiles) > 0 { + msg := tgbotapi.NewMediaGroup(userID, photoFiles) + responses, err := tg.api.SendMediaGroup(msg) + if err != nil { + js, _ := json.Marshal(msg) + return nil, fmt.Errorf("tg send photos: can't send json %s: %w", js, err) + } + for _, resp := range responses { + msgIDs = append(msgIDs, resp.MessageID) + } + } + if len(documentFiles) > 0 { + msg := tgbotapi.NewMediaGroup(userID, documentFiles) + responses, err := tg.api.SendMediaGroup(msg) + if err != nil { + js, _ := json.Marshal(msg) + return nil, fmt.Errorf("tg send documents: can't send json %s: %w", js, err) + } + for _, resp := range responses { + msgIDs = append(msgIDs, resp.MessageID) + } + } + + return msgIDs, nil +} + +func (tg *TG) SendReaction(userID int64, msgID int, reaction string) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/setMessageReaction", tg.api.Token) + + payload := map[string]interface{}{ + "chat_id": userID, + "message_id": msgID, + "reaction": []map[string]string{ + { + "type": "emoji", + "emoji": reaction, + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("telegram API error: %s", string(body)) + } + + return nil +} + +func (tg *TG) sendDocuments(userID int64, photos []string) ([]int, error) { + var files []interface{} + for _, img := range photos { + files = append(files, tgbotapi.NewInputMediaDocument(tgbotapi.FileID(img))) + } + + msg := tgbotapi.NewMediaGroup(userID, files) + + responses, err := tg.api.SendMediaGroup(msg) + if err != nil { + js, _ := json.Marshal(msg) + return nil, fmt.Errorf("tg send photos: can't send json %s: %w", js, err) + } + + var msgIDs []int + for _, resp := range responses { + msgIDs = append(msgIDs, resp.MessageID) + } + + return msgIDs, nil +} + +func (tg *TG) Edit(userID int64, msgID int, text string, kb *Keyboard, markup string) error { + msg := tgbotapi.NewEditMessageText(userID, msgID, text) + + msg.ReplyMarkup = tg.buildInlineKeyboard(kb) + msg.ParseMode = markup + + _, err := tg.api.Send(msg) + if err != nil { + return fmt.Errorf("tg edit: %w", err) + } + + return nil +} + +func (tg *TG) Del(userID int64, msgID int) error { + del := tgbotapi.NewDeleteMessage(userID, msgID) + + _, err := tg.api.Send(del) + if err != nil { + return fmt.Errorf("tg del: %w", err) + } + + return nil +} + +// AnswerCallbackQuery answers to incoming callbacks (keyboard's button clicks) +func (tg *TG) AnswerCallbackQuery(queryID string, text string) error { + _, err := tg.api.Send(tgbotapi.CallbackConfig{CallbackQueryID: queryID, Text: text}) + if err != nil { + return fmt.Errorf("tg can't answer to callback query: %w", err) + } + + return nil +} + +// AnswerInlineQuery answers to incoming inline queries (@BotMention ) +func (tg *TG) AnswerInlineQuery(queryID string, results []interface{}, cacheTime int, offset string) error { + config := tgbotapi.InlineConfig{ + InlineQueryID: queryID, + Results: results, + CacheTime: cacheTime, + NextOffset: offset, + IsPersonal: true, + } + _, err := tg.api.Send(config) + if err != nil { + return fmt.Errorf("tg can't answer to inline query: %w", err) + } + + return nil +} + +// DownloadFile returns downloaded file extension +func (tg *TG) DownloadFile(fileID string, outFile io.Writer) (string, error) { + file, err := tg.api.GetFile(tgbotapi.FileConfig{FileID: fileID}) + if err != nil { + if strings.Contains(err.Error(), "file is too big") { + return "", fmt.Errorf("tg can't get file: %w", ErrFileTooBig) + } + return "", fmt.Errorf("tg can't get file: %w", err) + } + + fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", tg.api.Token, file.FilePath) + resp, err := http.Get(fileURL) + if err != nil { + return "", fmt.Errorf("tg can't download file: %w", err) + } + defer resp.Body.Close() + + _, err = io.Copy(outFile, resp.Body) + if err != nil { + return "", fmt.Errorf("tg can't save file: %w", err) + } + + return path.Ext(file.FilePath), nil +} + +func (tg *TG) ChannelCreatorID(chatID int64) (int64, error) { + config := tgbotapi.ChatAdministratorsConfig{ + ChatConfig: tgbotapi.ChatConfig{ + ChatID: chatID, + }, + } + + admins, err := tg.api.GetChatAdministrators(config) + if err != nil { + return 0, err + } + + for _, admin := range admins { + if admin.Status == "creator" { + return admin.User.ID, nil + } + } + + return 0, fmt.Errorf("tg channel owner not found") +} + +func (tg *TG) buildInlineKeyboard(kb *Keyboard) *tgbotapi.InlineKeyboardMarkup { + if kb == nil || len(kb.Btns) == 0 { + return nil + } + + inlineKb := tgbotapi.NewInlineKeyboardMarkup() + for _, row := range kb.Btns { + switch typedRow := row.(type) { + case Btn: + btn := tg.buildBtn(typedRow) + inlineKb.InlineKeyboard = append(inlineKb.InlineKeyboard, tgbotapi.NewInlineKeyboardRow(btn)) + case []Btn: + inlineRow := tgbotapi.NewInlineKeyboardRow() + for _, b := range typedRow { + inlineRow = append(inlineRow, tg.buildBtn(b)) + } + inlineKb.InlineKeyboard = append(inlineKb.InlineKeyboard, inlineRow) + } + } + + return &inlineKb +} + +func (tg *TG) buildBtn(btn Btn) tgbotapi.InlineKeyboardButton { + serializedCmd, _ := json.Marshal(btn.Cmd) + button := tgbotapi.InlineKeyboardButton{ + Text: btn.Name, + } + switch btn.Cmd.Type { + + case CmdTypeInlineQueryCurrentChat: + { + param := "" + if len(btn.Cmd.Params) > 0 { + param = btn.Cmd.Params[0] + } + button.SwitchInlineQueryCurrentChat = ¶m + } + case CmdTypeWebApp: + { + param := "" + if len(btn.Cmd.Params) > 0 { + param = btn.Cmd.Params[0] + } + button.WebApp = &tgbotapi.WebAppInfo{URL: param} + } + case CmdTypeURL: + { + param := "" + if len(btn.Cmd.Params) > 0 { + param = btn.Cmd.Params[0] + } + button.URL = ¶m + } + default: + { + str := string(serializedCmd) + button.CallbackData = &str + } + } + + return button +} diff --git a/server/pkg/tg/tg_fake.go b/server/pkg/tg/tg_fake.go new file mode 100644 index 0000000..bc7b8e8 --- /dev/null +++ b/server/pkg/tg/tg_fake.go @@ -0,0 +1,88 @@ +package tg + +import ( + "io" +) + +type Message struct { + Text string + Buttons []Row +} + +type FakeTG struct { + SentTexts []string + LastSentText string + LastEditedText string + LastSentKeyboard *Keyboard + LastEditedKeyboard *Keyboard + InlineQueryResults []any + LastSentMessageID int + Messages []Message + EditedMessages []Message +} + +func NewFakeTG() *FakeTG { + return &FakeTG{} +} + +func (f *FakeTG) Send(userID int64, text string, kb *Keyboard, markup string) (int, error) { + f.LastSentText = text + f.SentTexts = append(f.SentTexts, text) + f.LastSentKeyboard = kb + f.LastEditedKeyboard = nil + f.LastEditedText = "" + f.LastSentMessageID++ + + msg := Message{ + Text: text, + } + if kb != nil { + msg.Buttons = kb.Btns + } + f.Messages = append(f.Messages, msg) + + return f.LastSentMessageID, nil +} + +func (tg *FakeTG) SendImages(userID int64, images []string) ([]int, error) { + return nil, nil +} + +func (tg *FakeTG) SendReaction(userID int64, msgID int, reaction string) error { return nil } + +func (f *FakeTG) Edit(userID int64, msgID int, text string, kb *Keyboard, markup string) error { + f.LastEditedText = text + f.LastEditedKeyboard = kb + f.LastSentKeyboard = nil + + msg := Message{ + Text: text, + } + if kb != nil { + msg.Buttons = kb.Btns + } + f.EditedMessages = append(f.EditedMessages, msg) + + return nil +} + +func (f *FakeTG) Del(userID int64, msgID int) error { + return nil +} + +func (f *FakeTG) AnswerCallbackQuery(queryID string, text string) error { + return nil +} + +func (f *FakeTG) AnswerInlineQuery(queryID string, results []interface{}, cacheTime int, offset string) error { + f.InlineQueryResults = results + return nil +} + +func (f *FakeTG) DownloadFile(fileID string, writer io.Writer) (string, error) { + return "", nil +} + +func (f *FakeTG) ChannelCreatorID(chatID int64) (int64, error) { + return 0, nil +} diff --git a/server/pkg/tg/tg_test.go b/server/pkg/tg/tg_test.go new file mode 100644 index 0000000..aaaa663 --- /dev/null +++ b/server/pkg/tg/tg_test.go @@ -0,0 +1,77 @@ +package tg + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/stretchr/testify/require" +) + +func TestSend(t *testing.T) { + r := require.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, err := w.Write([]byte(`{"ok":true, "result": {"id": -2}}`)) + r.NoError(err) + + if req.URL.Path == "/sendMessage" { + body, err := io.ReadAll(req.Body) + r.NoError(err) + r.Equal("chat_id=-1&entities=null&parse_mode=Html&text=t", string(body)) + } + })) + + api, _ := tgbotapi.NewBotAPIWithAPIEndpoint("", ts.URL+"%s/%s") + + tg := NewTG(api) + + _, err := tg.SendHTML(-1, "t", nil) + r.NoError(err) +} + +func TestEdit(t *testing.T) { + r := require.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, err := w.Write([]byte(`{"ok":true, "result": {"id": -2}}`)) + r.NoError(err) + + if req.URL.Path == "/editMessage" { + body, err := io.ReadAll(req.Body) + r.NoError(err) + r.Equal("chat_id=-1&entities=null&parse_mode=Html&text=t", string(body)) + } + })) + + api, _ := tgbotapi.NewBotAPIWithAPIEndpoint("", ts.URL+"%s/%s") + + tg := NewTG(api) + + err := tg.Edit(-1, -2, "t", nil, MarkupHTML) + r.NoError(err) +} + +func TestDel(t *testing.T) { + r := require.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, err := w.Write([]byte(`{"ok":true, "result": {"id": -2}}`)) + r.NoError(err) + + if req.URL.Path == "/deleteMessage" { + body, err := io.ReadAll(req.Body) + r.NoError(err) + r.Equal("chat_id=-1&message_id=-2", string(body)) + } + })) + + api, _ := tgbotapi.NewBotAPIWithAPIEndpoint("", ts.URL+"%s/%s") + + tg := NewTG(api) + + err := tg.Del(-1, -2) + r.NoError(err) +} diff --git a/server/pkg/tg/tg_upd.go b/server/pkg/tg/tg_upd.go new file mode 100644 index 0000000..6f919f8 --- /dev/null +++ b/server/pkg/tg/tg_upd.go @@ -0,0 +1,380 @@ +package tg + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "unicode/utf16" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var ( + errNoUserID = errors.New("update should always have at least one userID") + errNoChannelID = errors.New("update doesn't have channelID") + imageMimeTypes = []string{ + "image/gif", + "image/jpeg", + "image/pjpeg", + "image/png", + "image/tiff", + "image/webp", + } + videoMimeTypes = []string{ + "video/mp4", + "video/webm", + "video/quicktime", + } + audioMimeTypes = []string{ + "audio/mpeg", + "audio/mp3", + "audio/ogg", + "audio/wav", + "audio/x-wav", + "audio/webm", + } +) + +// TGUpd is a simple wrapper over Telegram Update object +type TGUpd struct { + raw tgbotapi.Update +} + +func NewTGUpd(u tgbotapi.Update) *TGUpd { + return &TGUpd{raw: u} +} + +func (u *TGUpd) MsgText() string { + if u.raw.Message != nil { + return u.raw.Message.Text + } + if u.raw.ChannelPost != nil { + return u.raw.ChannelPost.Text + } + + return "" +} + +func (u *TGUpd) UserID() int64 { + switch { + case u.raw.Message != nil: + return u.raw.Message.Chat.ID + case u.raw.CallbackQuery != nil && u.raw.CallbackQuery.Message != nil && u.raw.CallbackQuery.Message.Chat != nil: + return u.raw.CallbackQuery.Message.Chat.ID + case u.raw.InlineQuery != nil && u.raw.InlineQuery.From != nil: + return u.raw.InlineQuery.From.ID + default: + jsonData, _ := json.Marshal(u.raw) + panic(fmt.Sprintf("%v: %s", errNoUserID, string(jsonData))) + } +} + +func (u *TGUpd) ChannelID() (int64, bool) { + switch { + case u.raw.ChannelPost != nil: + return u.raw.ChannelPost.Chat.ID, true + case u.raw.EditedChannelPost != nil: + return u.raw.EditedChannelPost.Chat.ID, true + case u.raw.CallbackQuery != nil && u.raw.CallbackQuery.Message != nil && u.raw.CallbackQuery.Message.Chat != nil && u.raw.CallbackQuery.Message.Chat.Type == "channel": + return u.raw.CallbackQuery.Message.Chat.ID, true + default: + return 0, false + } +} + +func (u *TGUpd) ChannelName() (string, bool) { + switch { + case u.raw.ChannelPost != nil: + return u.raw.ChannelPost.Chat.Title, true + case u.raw.EditedChannelPost != nil: + return u.raw.EditedChannelPost.Chat.Title, true + default: + return "", false + } +} + +func (u *TGUpd) Cmd() *Cmd { + if u.raw.CallbackQuery != nil { + cmd := Cmd{} + _ = json.Unmarshal([]byte(u.raw.CallbackQuery.Data), &cmd) + + return &cmd + } + + for _, entity := range u.raw.Message.Entities { + if entity.IsCommand() { + slashedCommand := getTextByOffset(u.raw.Message.Text, entity.Offset, entity.Length) + + // Only return the command if the message is nothing else but the + // command itself. + rest := strings.TrimSpace(strings.Replace(u.raw.Message.Text, slashedCommand, "", 1)) + if rest != "" { + return nil + } + + cmd := NewCmd(strings.TrimPrefix(slashedCommand, "/"), nil) + return &cmd + } + } + + return nil +} + +func (u *TGUpd) MsgEntities() []tgbotapi.MessageEntity { + if u.raw.Message != nil { + return u.raw.Message.Entities + } + + return nil +} + +func (u *TGUpd) CaptionEntities() []tgbotapi.MessageEntity { + if u.raw.Message != nil { + if (u.raw.Message.CaptionEntities) != nil { + return u.raw.Message.CaptionEntities + } + } + + return nil +} + +func (u *TGUpd) CallbackQueryID() (string, bool) { + if u.raw.CallbackQuery == nil { + return "", false + } + + return u.raw.CallbackQuery.ID, true +} + +func (u *TGUpd) InlineQueryID() (string, bool) { + if u.raw.InlineQuery == nil { + return "", false + } + + return u.raw.InlineQuery.ID, true +} + +func (u *TGUpd) InlineQuery() (string, bool) { + if u.raw.InlineQuery == nil { + return "", false + } + + return u.raw.InlineQuery.Query, true +} + +func (u *TGUpd) InlineQueryOffset() int { + if u.raw.InlineQuery == nil { + return 0 + } + + offset, _ := strconv.Atoi(u.raw.InlineQuery.Offset) + + return offset +} + +func (u *TGUpd) IsForwarded() bool { + message := u.raw.Message + if message == nil { + return false + } + + if message.ForwardFromMessageID != 0 { + return true + } + + if message.ForwardFrom != nil { + return true + } + + if message.ForwardSenderName != "" { + return true + } + + return false +} + +func (u *TGUpd) IsSentViaBot() bool { + message := u.raw.Message + if message == nil { + return false + } + + return message.ViaBot != nil +} + +func (u *TGUpd) ReplyToMsgID() (int, bool) { + message := u.raw.Message + if message == nil { + return 0, false + } + + if message.ReplyToMessage == nil { + return 0, false + } + + return message.ReplyToMessage.MessageID, true +} + +func (u *TGUpd) PhotoOrImageID() (string, bool) { + photoID, found := u.photoID() + if found { + return photoID, true + } + + imageID, found := u.imageID() + if found { + return imageID, true + } + + videoID, found := u.videoID() + if found { + return videoID, true + } + + audioID, found := u.audioID() + if found { + return audioID, true + } + + return "", false +} + +// Caption returns the caption for the animation, audio, +// document, paid media, photo, video or voice +func (u *TGUpd) Caption() string { + message := u.raw.Message + if message == nil { + return "" + } + + return message.Caption +} + +func (u *TGUpd) photoID() (string, bool) { + message := u.raw.Message + if message == nil { + return "", false + } + + if message.Photo == nil { + return "", false + } + + // Pick the photo with the maximum size, as FakeTG + // makes some small crops + photoSize := 0 + photoID := "" + found := false + for _, photo := range message.Photo { + if photo.FileSize > photoSize { + photoSize = photo.FileSize + photoID = photo.FileID + found = true + } + } + + return photoID, found +} + +func (u *TGUpd) imageID() (string, bool) { + message := u.raw.Message + if message == nil { + return "", false + } + + if message.Document == nil { + return "", false + } + + if slices.Contains(imageMimeTypes, message.Document.MimeType) { + return message.Document.FileID, true + } + + return "", false +} + +// videoID returns the FileID of a video attached to the message - either as a +// native Telegram video, an animation (GIF / silent mp4), or a Document with a +// video MIME type. The bot saves the bytes via DownloadFile and the client +// renders them inline via fold-image.js's mp4/webm/mov detection. +func (u *TGUpd) videoID() (string, bool) { + message := u.raw.Message + if message == nil { + return "", false + } + + if message.Video != nil { + return message.Video.FileID, true + } + + if message.Animation != nil { + return message.Animation.FileID, true + } + + if message.Document != nil && slices.Contains(videoMimeTypes, message.Document.MimeType) { + return message.Document.FileID, true + } + + return "", false +} + +// audioID returns the FileID of an audio attachment - either a Telegram voice +// message (ogg/opus), a native audio file, or a Document with an audio MIME +// type. Same downstream path as photo/video: DownloadFile -> media/ -> +// fold-image.js renders an