InícioDocs para devsComo criar, organizar e compartilhar documentos de diário
Tutoriais de implementação 13 min de leitura

Como criar, organizar e compartilhar documentos de diário

Crie diários ricos nativos, preserve páginas ao editar, organize pastas e controle acesso e apresentações.

Módulo Journal management tools instalado após executar a receita directory. Gravewright 0.3.0, interface em inglês.
Módulo Journal management tools instalado após executar a receita directory. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O projeto que você vai criar

Comece lendo o diretório nativo. Crie diário original, renomeie sem substituir páginas, mova na árvore de pastas, dê leitura a um jogador e apresente uma página pública. As receitas finais removem somente recursos de tutorial confirmados explicitamente.

Host alvo: Gravewright 0.3.0. As ações rodam somente quando você clica Run action; leituras e escritas usam a conta conectada. O painel de demonstração está em inglês. Cada receita abaixo informa os dados de entrada e o resultado concreto.

1. Crie os arquivos

Crie uma pasta journal-management. Salve os arquivos mostrados abaixo em UTF-8, ou baixe o projeto completo. manifest.json fica na raiz. Não há dependências npm nem build de JavaScript.

text
journal-management/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Use campanha descartável como mestre para projeções completas e ações destrutivas. Copie UUIDs de usuários de members, pastas de folders e documentos de journals. ID de página vem de sections[].id, não de cena ou ator. Apresentação direcionada exige o recurso nativo da instalação.

2. Empacote, instale e ative

Partindo da pasta do projeto, execute no PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../journal-management-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../journal-management-1.0.0.zip

No Linux/macOS, substitua python por python3 no mesmo comando. A listagem precisa mostrar manifest.json na raiz. Como proprietário, abra Inside → Módulos → Instalar módulo → Instalar de ZIP, selecione o ZIP e clique Instalar. Como GM, abra a campanha, use o botão Gravewright da mesa → Extensions e clique Activate na versão 1.0.0 de Journal management tools. Feche o menu e clique no botão Journal management tools. Se precisar de detalhes de cada tela, veja seu primeiro módulo.

O projeto fica inativo até a seleção na mesa. Teste uma extensão de demonstração por vez, pois seus botões usam a mesma posição. Para novos bytes, mude version para 1.0.1, gere outro ZIP, instale e ative essa versão; não edite o pacote instalado.

3. Execute cada receita

Ler documentos, pastas e IDs de membros

Execute primeiro como mestre para obter journals[].id, folders[].id e members[].id. O último é UUID de usuário para setAccess e apresentação, não UUID de ator. A projeção do jogador omite a lista de membros do mestre e conteúdo privado.

javascript
async ({api,signal})=>api.journals.state({}, {signal})

Resultado esperado: O diretório autorizado com tipos nativos, permissões e flags de recursos.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Criar um diário com documento estruturado

Crie um diário e preencha content com o envelope gw-journal-doc-v1. HTML puro ou uma string Markdown não são esse formato de documento rico. Copie o ID retornado.

javascript
async ({api,signal},v)=>{const created=await api.journals.create({title:v.title,journal_type:'diary'},{signal,requestId:crypto.randomUUID()});try{const doc=text=>({format:'gw-journal-doc-v1',version:1,doc:{type:'doc',content:[{type:'paragraph',content:[{type:'text',text}]}]}});await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,data:{content:doc(v.body),cover:{},sections:[],gm:{}}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Diary '+created.journal_id+' exists; setup failed: '+(error.code||error.message)+'. Inspect it before creating another.');}}

Resultado esperado: Um diário privado cujo editor nativo mostra o parágrafo original.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Renomear diário sem apagar páginas ou notas do mestre

Como mestre, reconstrua o formato de gravação: content_doc vira content, cover_image vira cover e diary.gm vira gm. Preserve sections e a versão lida. A receita atende diary, não formatos de missão ou tabela.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('Use GM to preserve the full diary projection.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='diary')throw new Error('Choose a diary.');const data={content:row.content_doc,cover:row.cover_image,sections:row.sections,gm:row.diary.gm};await api.journals.update({journal_id:row.id,version:row.version,title:v.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}

Resultado esperado: Somente o título muda; páginas e dados do mestre permanecem.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Mover documento para pasta ou raiz

Como mestre, use UUID de pasta do diretório. Destino vazio escolhe a raiz. A ação altera organização, não visibilidade ou conteúdo.

javascript
async ({api,signal},v)=>{await api.journals.move({journal_id:v.id,target_folder_id:v.folderId.trim()||null},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}

Resultado esperado: folder_id passa a ser o UUID de destino ou null.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Conceder ou revogar acesso de um membro

Use members[].id do diretório do mestre. none nega acesso, limited permite listagem, read abre conteúdo autorizado e owner permite edição conforme o servidor. A concessão persiste, diferente da apresentação temporária.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(!['none','limited','read','owner'].includes(v.level))throw new Error('Invalid access level.');await api.journals.setAccess({journal_id:v.id,target_user_id:v.userId,access_level:v.level},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}

Resultado esperado: Uma concessão nativa por usuário para o diário.

Como conferir: Confira lista e editor do jogador alvo. Blocos exclusivos do mestre devem continuar ocultos mesmo com leitura.

Apresentar documento ou página pública filtrados

Deixe usuário vazio para todos ou escolha um membro quando apresentações direcionadas estiverem habilitadas. O ID opcional deve ser de página pública do diário. ownership unchanged evita alterar o acesso permanente.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const payload={journal_id:v.id,ownership:'unchanged',...(v.userId.trim()?{targets:[v.userId.trim()]}:{}),...(v.pageId.trim()?{page_id:v.pageId.trim()}:{})};return api.journals.present(payload,{signal,requestId:crypto.randomUUID()});}

Resultado esperado: Uma apresentação nativa é entregue aos destinatários com leitura temporária e conteúdo filtrado para jogador.

Como conferir: Confirme recebimento na conta alvo e ausência na outra. Tente página exclusiva do mestre e confira a rejeição.

Criar pasta com cor validada

Informe nome e, opcionalmente, UUID de pasta pai. A cor nativa é uma string hexadecimal de seis dígitos. Copie result.folder_id.

javascript
async ({api,signal},v)=>{const result=await api.journals.createFolder({name:v.name,color:'#c09a5a',parent_id:v.parentId.trim()||null},{signal,requestId:crypto.randomUUID()});return {result,directory:await api.journals.state({}, {signal})};}

Resultado esperado: Uma pasta nova vinculada à pasta pai escolhida.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Renomear e recolorir pasta

Use o ID da pasta criada. A API exige nome e cor; envie ambos explicitamente.

javascript
async ({api,signal},v)=>{await api.journals.updateFolder({folder_id:v.folderId,name:v.name,color:v.color},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}

Resultado esperado: O mesmo ID de pasta com novo nome e cor.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Mover pasta sem criar ciclos

Escolha pasta existente e novo pai. Pai vazio move para a raiz. O servidor rejeita mover uma pasta para dentro de si ou de uma descendente.

javascript
async ({api,signal},v)=>{await api.journals.moveFolder({folder_id:v.folderId,target_parent_id:v.parentId.trim()||null},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}

Resultado esperado: A pasta tem novo parent_id e mantém seus filhos.

Como conferir: Crie uma filha e tente mover a mãe para dentro dela; a ação deve falhar sem alterar a árvore.

Apagar pasta preservando o conteúdo

Use apenas a pasta sintética e digite seu nome exato. A exclusão nativa move pastas e documentos filhos para a antiga pasta pai. Não apaga diários recursivamente.

javascript
async ({api,signal},v)=>{const state=await api.journals.state({}, {signal});const folder=state.folders.find(folder=>folder.id===v.folderId);if(!folder)throw new Error('Folder not found.');if(v.confirmation!==folder.name)throw new Error('Type the exact folder name.');await api.journals.deleteFolder({folder_id:folder.id},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}

Resultado esperado: A pasta desaparece; seus documentos permanecem na pasta pai ou raiz.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

Apagar um documento descartável e atualizar a lista

Como mestre, digite o título exato do documento de teste. A ação apaga esse diário e referências nativas relacionadas; difere de remover vínculo de quadro.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const row=await api.journals.get(v.id,{}, {signal});if(v.confirmation!==row.title)throw new Error('Type the exact document title.');await api.journals.delete({journal_id:row.id},{signal,requestId:crypto.randomUUID()});return api.journals.list({}, {signal});}

Resultado esperado: A lista retornada omite o UUID apagado.

Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.

4. Entenda e adapte os arquivos completos

main.mjs desenha apenas o lançador e o formulário. recipes.mjs contém as ações: para adaptar o comportamento, altere a função run da receita desejada, mantendo sua assinatura. O formulário passa tipos reais (number, boolean e File), bloqueia requisições simultâneas e desenha resultados com textContent. Não há avaliação de código fornecido pelo usuário. api, host, assets e storage pertencem à montagem; signal cancela trabalho ao desativar. O modal não fecha durante uma operação pendente e é liberado ao abortar a montagem. A função de negócio continua responsável por revisões, permissões e sequência transacional.

manifest.json

json
{
  "id": "tutorial.journal-management",
  "name": "Journal management tools",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Build native rich diaries, preserve pages when editing, organize folder trees and control access and presentations.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="Journal management tools";
      root.append(button);
      for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
      let modal,pending=false;
      button.addEventListener('click',event=>{
        event.stopPropagation();
        if(modal?.isConnected) { modal.focus();return; }
        const panel=doc.createElement('section');
        panel.className="tutorial-workbench-journal-management";
        panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
        panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
        const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
        const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
        for(const recipe of recipes) {
          const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
        }
        const form=doc.createElement('form'),fields=doc.createElement('div');
        const description=doc.createElement('p');
        const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
        const close=doc.createElement('button');close.type='button';close.textContent='Close';
        const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
        form.append(description,fields,run,close,output);panel.append(choose,form);
        function renderFields(){
          const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
          description.textContent=recipe.description;
          for(const field of recipe.fields||[]) {
            const label=doc.createElement('label');label.textContent=field.label;
            const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
            if(field.type==='number')input.step=String(field.step??'any');
            if(field.type==='checkbox')input.checked=!!field.value;
            else if(field.type!=='file')input.value=String(field.value??'');
            input.required=!!field.required;
            if(field.accept)input.accept=field.accept;
            label.append(input);fields.append(label);
          }
          output.textContent='';
        }
        choose.addEventListener('change',renderFields,{signal});renderFields();
        form.addEventListener('submit',async event=>{
          event.preventDefault();if(pending)return;
          const recipe=recipes.find(item=>item.id===choose.value),values={};
          for(const input of fields.querySelectorAll('input')) {
            values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
          }
          pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
          for(const input of fields.querySelectorAll('input'))input.disabled=true;
          output.textContent='Running…';
          try {
            const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
            if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
          } catch(error) {
            if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
          } finally {
            pending=false;
            if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
          }
        },{signal});
        close.addEventListener('click',()=>void modal.close(),{signal});
        modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
      },{signal});
      onDispose(()=>{button.remove();if(modal)void modal.close();});
    });
  },
  stop() {}
};

recipes.mjs

javascript
export const recipes=[
{
  id:"directory",
  label:"Read documents, folders and campaign member IDs",
  description:"Run first as GM to obtain journals[].id, folders[].id and members[].id. The last field is a user UUID for setAccess and presentation, not an actor UUID. Player projections omit the GM member directory and private content.",
  fields:[],
  run:async ({api,signal})=>api.journals.state({}, {signal})
},
{
  id:"create-diary",
  label:"Create a real block-document diary",
  description:"Create a diary, then fill content using the supported gw-journal-doc-v1 envelope. Plain HTML or a Markdown string is not that rich-document format. Copy the returned ID.",
  fields:[
  {
    "name": "title",
    "label": "Original diary title",
    "type": "text",
    "value": "Harbor log",
    "required": true
  },
  {
    "name": "body",
    "label": "Original body text",
    "type": "text",
    "value": "The harbor beacon needs repair.",
    "required": true
  }
],
  run:async ({api,signal},v)=>{const created=await api.journals.create({title:v.title,journal_type:'diary'},{signal,requestId:crypto.randomUUID()});try{const doc=text=>({format:'gw-journal-doc-v1',version:1,doc:{type:'doc',content:[{type:'paragraph',content:[{type:'text',text}]}]}});await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,data:{content:doc(v.body),cover:{},sections:[],gm:{}}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Diary '+created.journal_id+' exists; setup failed: '+(error.code||error.message)+'. Inspect it before creating another.');}}
},
{
  id:"rename-diary",
  label:"Rename a diary without erasing its pages or GM notes",
  description:"As GM, reconstruct the write shape from the read projection: content_doc becomes content, cover_image becomes cover, and diary.gm becomes gm. Preserve sections and the exact read version. This example deliberately targets diary, not quest/table shapes.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "title",
    "label": "New diary title",
    "type": "text",
    "value": "Harbor log, reviewed",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('Use GM to preserve the full diary projection.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='diary')throw new Error('Choose a diary.');const data={content:row.content_doc,cover:row.cover_image,sections:row.sections,gm:row.diary.gm};await api.journals.update({journal_id:row.id,version:row.version,title:v.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
  id:"move-document",
  label:"Move a document into a folder or the root",
  description:"As GM, use a folder UUID from the directory. Blank target selects the root. This changes organization rather than visibility or content.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "folderId",
    "label": "Target folder UUID, blank for root",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>{await api.journals.move({journal_id:v.id,target_folder_id:v.folderId.trim()||null},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
},
{
  id:"access",
  label:"Grant or revoke one member’s journal access",
  description:"Use members[].id from the GM directory. none denies access, limited allows listing, read opens authorized content, and owner permits editing under server rules. This persists a grant, unlike a temporary presentation.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "userId",
    "label": "Campaign user UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "level",
    "label": "none/limited/read/owner",
    "type": "text",
    "value": "read",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(!['none','limited','read','owner'].includes(v.level))throw new Error('Invalid access level.');await api.journals.setAccess({journal_id:v.id,target_user_id:v.userId,access_level:v.level},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
},
{
  id:"present",
  label:"Present a player-filtered document or public page",
  description:"Leave user UUID blank to present to everyone, or target one campaign user when targeted handouts are enabled. Optional page ID must refer to a public diary page. ownership unchanged avoids a permanent access change.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "userId",
    "label": "Target user UUID, blank for everyone",
    "type": "text",
    "value": "",
    "required": false
  },
  {
    "name": "pageId",
    "label": "Public diary page ID, optional",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const payload={journal_id:v.id,ownership:'unchanged',...(v.userId.trim()?{targets:[v.userId.trim()]}:{}),...(v.pageId.trim()?{page_id:v.pageId.trim()}:{})};return api.journals.present(payload,{signal,requestId:crypto.randomUUID()});}
},
{
  id:"folder-create",
  label:"Create a folder with a validated color",
  description:"Enter a name and optionally an existing parent UUID. The native color is a six-digit hexadecimal string. Copy result.folder_id.",
  fields:[
  {
    "name": "name",
    "label": "Folder name",
    "type": "text",
    "value": "Harbor notes",
    "required": true
  },
  {
    "name": "parentId",
    "label": "Parent folder UUID, optional",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>{const result=await api.journals.createFolder({name:v.name,color:'#c09a5a',parent_id:v.parentId.trim()||null},{signal,requestId:crypto.randomUUID()});return {result,directory:await api.journals.state({}, {signal})};}
},
{
  id:"folder-update",
  label:"Rename and recolor a folder",
  description:"Use the folder ID created above. This API requires both name and color, so send them explicitly.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "name",
    "label": "New folder name",
    "type": "text",
    "value": "Harbor archive",
    "required": true
  },
  {
    "name": "color",
    "label": "Six-digit color",
    "type": "text",
    "value": "#5f8fba",
    "required": true
  }
],
  run:async ({api,signal},v)=>{await api.journals.updateFolder({folder_id:v.folderId,name:v.name,color:v.color},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}
},
{
  id:"folder-move",
  label:"Move a folder without creating cycles",
  description:"Choose an existing folder and new parent. A blank parent moves to root. The server rejects moving a folder into itself or a descendant.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "parentId",
    "label": "New parent UUID, blank for root",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>{await api.journals.moveFolder({folder_id:v.folderId,target_parent_id:v.parentId.trim()||null},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}
},
{
  id:"folder-delete",
  label:"Delete a folder while retaining its contents",
  description:"Use only the synthetic folder and type its exact name. Native deletion reparents its child folders and documents to its former parent. It does not recursively delete journals.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "confirmation",
    "label": "Type the exact folder name",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,signal},v)=>{const state=await api.journals.state({}, {signal});const folder=state.folders.find(folder=>folder.id===v.folderId);if(!folder)throw new Error('Folder not found.');if(v.confirmation!==folder.name)throw new Error('Type the exact folder name.');await api.journals.deleteFolder({folder_id:folder.id},{signal,requestId:crypto.randomUUID()});return api.journals.state({}, {signal});}
},
{
  id:"delete-document",
  label:"Delete one disposable document and refresh the list",
  description:"As GM, type the exact title of the tutorial document. This deletes that journal and related native references; it is different from removing a board link.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "confirmation",
    "label": "Type the exact document title",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const row=await api.journals.get(v.id,{}, {signal});if(v.confirmation!==row.title)throw new Error('Type the exact document title.');await api.journals.delete({journal_id:row.id},{signal,requestId:crypto.randomUUID()});return api.journals.list({}, {signal});}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.journal-management"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-journal-management { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-journal-management label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-journal-management input, .tutorial-workbench-journal-management select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-journal-management pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-journal-management button { margin-right: var(--space-3); }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

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.

5. Permissões, falhas e teste final

Operações de pasta não apagam documentos recursivamente: excluir pasta move seus documentos e filhos para a pasta pai. Excluir documento é separado e exige título exato neste exemplo. update exige versão exata e envelope do tipo correto; a receita de diário reconstrói a projeção de mestre preservando páginas, capa e notas privadas. A concessão de acesso persiste; apresentar com ownership unchanged dá leitura temporária filtrada para jogador. Confira destinatários, páginas privadas, ciclos na árvore e edições desatualizadas.

Como usar a IA para estender este projeto

text
Estenda journal-management no Gravewright 0.3.0 com [um fluxo]. Leia journals/services.py, documents.py, data.py e presentations.py. Preserve gw-journal-doc-v1, conversão dos formatos de leitura/gravação, versões exatas, contenção de pastas, acesso direcionado e filtragem privada. Entregue receitas completas e testes mestre/jogador, movimentos repetidos, ciclos rejeitados, privacidade de páginas e exclusão separada de documentos/pastas.

Todos os tutoriais · API